From 6723ddefc99a3115fc4a4faf7989a9516a6e30ca Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 13 May 2026 19:29:22 -0700 Subject: [PATCH 01/81] Add remote activity SDK APIs --- src/Grpc/orchestrator_service.proto | 5 + src/Grpc/serverless_activities_service.proto | 69 ++++ .../DurableTaskSchedulerWorkerExtensions.cs | 203 +++++++++++ .../Serverless/RemoteActivityConfiguration.cs | 190 ++++++++++ .../RemoteActivityDeclarationHostedService.cs | 161 +++++++++ .../Serverless/RemoteActivityOptions.cs | 75 ++++ .../Serverless/RemoteActivityWorkerOptions.cs | 35 ++ ...ActivityWorkerRegistrationHostedService.cs | 218 ++++++++++++ .../ServerlessActivitiesClientAdapter.cs | 114 ++++++ .../DurableTaskWorkerBuilderExtensions.cs | 3 + ...rableTaskWorkerWorkItemFiltersValidator.cs | 5 + .../Core/DurableTaskWorkerWorkItemFilters.cs | 6 + ...rableTaskWorkerWorkItemFiltersExtension.cs | 10 + .../RemoteActivitiesTests.cs | 325 ++++++++++++++++++ 14 files changed, 1419 insertions(+) create mode 100644 src/Grpc/serverless_activities_service.proto create mode 100644 src/Worker/AzureManaged/Serverless/RemoteActivityConfiguration.cs create mode 100644 src/Worker/AzureManaged/Serverless/RemoteActivityDeclarationHostedService.cs create mode 100644 src/Worker/AzureManaged/Serverless/RemoteActivityOptions.cs create mode 100644 src/Worker/AzureManaged/Serverless/RemoteActivityWorkerOptions.cs create mode 100644 src/Worker/AzureManaged/Serverless/RemoteActivityWorkerRegistrationHostedService.cs create mode 100644 src/Worker/AzureManaged/Serverless/ServerlessActivitiesClientAdapter.cs create mode 100644 test/Worker/AzureManaged.Tests/RemoteActivitiesTests.cs diff --git a/src/Grpc/orchestrator_service.proto b/src/Grpc/orchestrator_service.proto index 3d7c8eb4..f782a5fe 100644 --- a/src/Grpc/orchestrator_service.proto +++ b/src/Grpc/orchestrator_service.proto @@ -856,6 +856,11 @@ message WorkItemFilters { repeated OrchestrationFilter orchestrations = 1; repeated ActivityFilter activities = 2; repeated EntityFilter entities = 3; + // Activities the worker explicitly does NOT want to process. When set, + // matching activity work items are skipped for this connection even if + // they would otherwise match `activities`. Mutually exclusive with + // `activities` for the same name. + repeated ActivityFilter exclude_activities = 4; } message OrchestrationFilter { diff --git a/src/Grpc/serverless_activities_service.proto b/src/Grpc/serverless_activities_service.proto new file mode 100644 index 00000000..ee3bbb79 --- /dev/null +++ b/src/Grpc/serverless_activities_service.proto @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +syntax = "proto3"; + +package microsoft.durabletask.serverless; + +option csharp_namespace = "Microsoft.DurableTask.Protobuf.Serverless"; + +service ServerlessActivities { + // Opens a live remote activity worker session. The first message must be a + // start message with static worker metadata. Heartbeats carry dynamic state + // only. Closing the stream deregisters the worker. + rpc ConnectRemoteActivityWorker(stream RemoteActivityWorkerMessage) returns (RemoteActivityWorkerSessionResult); + + // Declares remote activities before any live worker stream exists. This is a + // configuration contract and does not advertise active worker capacity. + rpc DeclareRemoteActivities(RemoteActivityDeclaration) returns (RemoteActivityDeclarationResult); +} + +message RemoteActivityWorkerMessage { + oneof message { + RemoteActivityWorkerStart start = 1; + RemoteActivityWorkerHeartbeat heartbeat = 2; + } +} + +message RemoteActivityWorkerStart { + string task_hub = 1; + string worker_instance_id = 2; + int32 max_activities_count = 3; + // Substrate the worker is running in. UNSPECIFIED = legacy (pre-substrate-aware) workers. + SubstrateKind substrate = 4; + // Identifier of the ADC sandbox the worker is running inside. Empty when substrate != SANDBOX. + string sandbox_id = 5; +} + +message RemoteActivityWorkerHeartbeat { + int32 active_activities_count = 1; +} + +message RemoteActivityWorkerSessionResult { + bool accepted = 1; + string message = 2; +} + +message RemoteActivityDeclaration { + string task_hub = 1; + string worker_profile_id = 2; + repeated string activity_names = 3; + RemoteActivityImage image = 4; + map environment_variables = 5; + int32 max_concurrent_activities = 6; +} + +message RemoteActivityImage { + string image_ref = 1; + bool public_pull = 2; +} + +message RemoteActivityDeclarationResult { +} + +// Compute substrate executing the activity worker. +enum SubstrateKind { + SUBSTRATE_KIND_UNSPECIFIED = 0; + SUBSTRATE_KIND_ACA_SESSION_POOL = 1; + SUBSTRATE_KIND_SANDBOX = 2; +} diff --git a/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs b/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs index 3b9d4e55..c25bbf10 100644 --- a/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs +++ b/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs @@ -7,10 +7,14 @@ using System.Threading; using Azure.Core; using Grpc.Net.Client; +using Microsoft.DurableTask.Protobuf.Serverless; +using Microsoft.DurableTask.Worker.AzureManaged.Serverless; using Microsoft.DurableTask.Worker.Grpc; using Microsoft.DurableTask.Worker.Grpc.Internal; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Microsoft.DurableTask.Worker.AzureManaged; @@ -20,6 +24,87 @@ namespace Microsoft.DurableTask.Worker.AzureManaged; /// public static class DurableTaskSchedulerWorkerExtensions { + /// + /// Declares remote activities and configures the local worker to exclude them from local execution. + /// + /// The Durable Task worker builder to configure. + /// Optional callback to configure remote activity declaration behavior. + /// The original builder, for call chaining. + public static IDurableTaskWorkerBuilder DeclareRemoteActivities( + this IDurableTaskWorkerBuilder builder, + Action? configure = null) + { + Check.NotNull(builder); + + builder.Services.AddOptions(builder.Name) + .Configure(configure ?? (_ => { })) + .PostConfigure>((options, schedulerOptions) => + { + ApplyTaskHubDefault(options, schedulerOptions.Get(builder.Name).TaskHubName); + ApplyRemoteActivityEnvironmentOverrides(options); + }); + + builder.Services.AddOptions(builder.Name) + .PostConfigure>( + (filters, remoteActivityOptions) => + { + RemoteActivityOptions options = remoteActivityOptions.Get(builder.Name); + string[] activityNames = RemoteActivityConfiguration.ResolveActivityNames(options.ActivityNames); + if (activityNames.Length == 0) + { + return; + } + + filters.ExcludedActivities = MergeActivityFilters(filters.ExcludedActivities, activityNames); + }); + + builder.Services.AddSingleton(sp => CreateRemoteActivityDeclarationHostedService(sp, builder.Name)); + return builder; + } + + /// + /// Configures this worker as a sandbox remote activity worker and registers live capacity with DTS. + /// + /// The Durable Task worker builder to configure. + /// Optional callback to configure remote activity worker behavior. + /// The original builder, for call chaining. + public static IDurableTaskWorkerBuilder UseRemoteActivityWorker( + this IDurableTaskWorkerBuilder builder, + Action? configure = null) + { + Check.NotNull(builder); + + builder.Services.AddOptions(builder.Name) + .Configure(configure ?? (_ => { })) + .PostConfigure>((options, schedulerOptions) => + { + ApplyTaskHubDefault(options, schedulerOptions.Get(builder.Name).TaskHubName); + ApplyRemoteActivityWorkerEnvironmentOverrides(options); + }); + + builder.Services.AddOptions(builder.Name) + .PostConfigure>( + (filters, remoteActivityWorkerOptions) => + { + RemoteActivityWorkerOptions options = remoteActivityWorkerOptions.Get(builder.Name); + string[] activityNames = RemoteActivityConfiguration.ResolveActivityNames(options.ActivityNames); + if (activityNames.Length == 0) + { + return; + } + + filters.Orchestrations = []; + filters.Activities = activityNames + .Select(static name => new DurableTaskWorkerWorkItemFilters.ActivityFilter { Name = name }) + .ToArray(); + filters.ExcludedActivities = []; + filters.Entities = []; + }); + + builder.Services.AddSingleton(sp => CreateRemoteActivityWorkerRegistrationHostedService(sp, builder.Name)); + return builder; + } + /// /// Configures Durable Task worker to use the Azure Durable Task Scheduler service. /// @@ -103,6 +188,123 @@ static void ConfigureSchedulerOptions( builder.UseGrpc(_ => { }); } + static RemoteActivityDeclarationHostedService CreateRemoteActivityDeclarationHostedService( + IServiceProvider services, + string builderName) + { + RemoteActivityOptions options = services.GetRequiredService>().Get(builderName); + ILoggerFactory loggerFactory = services.GetRequiredService(); + + return new RemoteActivityDeclarationHostedService( + CreateServerlessActivitiesClient(services, builderName), + options, + loggerFactory.CreateLogger()); + } + + static RemoteActivityWorkerRegistrationHostedService CreateRemoteActivityWorkerRegistrationHostedService(IServiceProvider services, string builderName) + { + RemoteActivityWorkerOptions options = services.GetRequiredService>().Get(builderName); + ILoggerFactory loggerFactory = services.GetRequiredService(); + IHostApplicationLifetime? lifetime = services.GetService(); + + return new RemoteActivityWorkerRegistrationHostedService( + CreateServerlessActivitiesClient(services, builderName), + options, + loggerFactory.CreateLogger(), + lifetime); + } + + static ServerlessActivitiesClientAdapter CreateServerlessActivitiesClient(IServiceProvider services, string builderName) + { + GrpcDurableTaskWorkerOptions options = services.GetRequiredService>().Get(builderName); + if (options.CallInvoker is { } callInvoker) + { + return new ServerlessActivitiesClientAdapter(new ServerlessActivities.ServerlessActivitiesClient(callInvoker)); + } + + if (options.Channel is { } channel) + { + return new ServerlessActivitiesClientAdapter(new ServerlessActivities.ServerlessActivitiesClient(channel.CreateCallInvoker())); + } + + throw new InvalidOperationException("Azure Managed remote activities require a configured gRPC channel or call invoker."); + } + + static void ApplyTaskHubDefault(RemoteActivityOptions options, string taskHubName) + { + if (string.IsNullOrWhiteSpace(options.TaskHub) && !string.IsNullOrWhiteSpace(taskHubName)) + { + options.TaskHub = taskHubName; + } + } + + static void ApplyTaskHubDefault(RemoteActivityWorkerOptions options, string taskHubName) + { + if (string.IsNullOrWhiteSpace(options.TaskHub) && !string.IsNullOrWhiteSpace(taskHubName)) + { + options.TaskHub = taskHubName; + } + } + + static void ApplyRemoteActivityEnvironmentOverrides(RemoteActivityOptions options) + { + ApplyActivityNameEnvironmentOverride(options.ActivityNames); + + string? image = Environment.GetEnvironmentVariable("DTS_REMOTE_ACTIVITY_IMAGE"); + if (!string.IsNullOrWhiteSpace(image)) + { + options.ContainerImage = image; + } + } + + static void ApplyRemoteActivityWorkerEnvironmentOverrides(RemoteActivityWorkerOptions options) + { + ApplyActivityNameEnvironmentOverride(options.ActivityNames); + + if (int.TryParse(Environment.GetEnvironmentVariable("DTS_SERVERLESS_MAX_ACTIVITIES"), out int maxActivities) && maxActivities > 0) + { + options.MaxConcurrentActivities = maxActivities; + } + } + + static void ApplyActivityNameEnvironmentOverride(ICollection activityNames) + { + string? remoteActivities = Environment.GetEnvironmentVariable("DTS_REMOTE_ACTIVITIES"); + if (remoteActivities is null) + { + return; + } + + activityNames.Clear(); + foreach (string name in remoteActivities + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Distinct(StringComparer.Ordinal)) + { + activityNames.Add(name); + } + } + + static DurableTaskWorkerWorkItemFilters.ActivityFilter[] MergeActivityFilters( + IReadOnlyList existingFilters, + IEnumerable activityNames) + { + Dictionary merged = new(StringComparer.OrdinalIgnoreCase); + foreach (DurableTaskWorkerWorkItemFilters.ActivityFilter filter in existingFilters) + { + if (!string.IsNullOrWhiteSpace(filter.Name)) + { + merged[filter.Name] = filter; + } + } + + foreach (string activityName in activityNames) + { + merged[activityName] = new DurableTaskWorkerWorkItemFilters.ActivityFilter { Name = activityName }; + } + + return merged.Values.ToArray(); + } + /// /// Configuration class that sets up gRPC channels for worker options /// using the provided Durable Task Scheduler options. @@ -300,6 +502,7 @@ and not AccessViolationException } } } + GC.SuppressFinalize(this); } diff --git a/src/Worker/AzureManaged/Serverless/RemoteActivityConfiguration.cs b/src/Worker/AzureManaged/Serverless/RemoteActivityConfiguration.cs new file mode 100644 index 00000000..528bbd1f --- /dev/null +++ b/src/Worker/AzureManaged/Serverless/RemoteActivityConfiguration.cs @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Proto = Microsoft.DurableTask.Protobuf.Serverless; + +namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; + +/// +/// Builds and normalizes remote activity protocol messages. +/// +static class RemoteActivityConfiguration +{ + /// + /// Resolves configured activity names for a remote activity worker. + /// + /// The configured activity names. + /// The normalized activity names. + public static string[] ResolveActivityNames(ICollection configuredNames) + { + return configuredNames + .Where(static name => !string.IsNullOrWhiteSpace(name)) + .Select(static name => name.Trim()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + /// + /// Builds a remote activity declaration protocol message. + /// + /// The declaration options. + /// The activity names included in the declaration. + /// The declaration protocol message. + public static Proto.RemoteActivityDeclaration BuildDeclaration(RemoteActivityOptions options, IReadOnlyCollection activityNames) + { + Check.NotNull(options); + Check.NotNull(activityNames); + + if (string.IsNullOrWhiteSpace(options.TaskHub)) + { + throw new InvalidOperationException("Remote activity declaration requires a task hub name."); + } + + if (activityNames.Count == 0) + { + throw new InvalidOperationException("Remote activity declaration requires at least one activity name."); + } + + if (options.MaxConcurrentActivities <= 0) + { + throw new InvalidOperationException("Remote activity max concurrent activities must be greater than zero."); + } + + Proto.RemoteActivityDeclaration declaration = new() + { + TaskHub = options.TaskHub, + WorkerProfileId = RemoteActivityOptions.DefaultWorkerProfileId, + Image = BuildImage(options), + MaxConcurrentActivities = options.MaxConcurrentActivities, + }; + + declaration.ActivityNames.AddRange(activityNames); + declaration.EnvironmentVariables.Add(options.EnvironmentVariables); + return declaration; + } + + /// + /// Builds the initial remote activity worker registration message. + /// + /// The worker options. + /// The worker start protocol message. + public static Proto.RemoteActivityWorkerMessage BuildWorkerStart(RemoteActivityWorkerOptions options) + { + Check.NotNull(options); + + if (string.IsNullOrWhiteSpace(options.TaskHub)) + { + throw new InvalidOperationException("Remote activity worker registration requires a task hub name."); + } + + if (options.MaxConcurrentActivities <= 0) + { + throw new InvalidOperationException("Remote activity worker max concurrent activities must be greater than zero."); + } + + Proto.RemoteActivityWorkerStart start = new() + { + TaskHub = options.TaskHub, + WorkerInstanceId = options.WorkerInstanceId, + MaxActivitiesCount = options.MaxConcurrentActivities, + Substrate = GetSubstrateFromEnvironment(), + SandboxId = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID") ?? string.Empty, + }; + + return new Proto.RemoteActivityWorkerMessage { Start = start }; + } + + /// + /// Builds a remote activity worker heartbeat message. + /// + /// The number of activities currently executing. + /// The heartbeat protocol message. + public static Proto.RemoteActivityWorkerMessage BuildWorkerHeartbeat(int activeActivitiesCount) + { + if (activeActivitiesCount < 0) + { + throw new InvalidOperationException("Remote activity worker active activity count cannot be negative."); + } + + return new Proto.RemoteActivityWorkerMessage + { + Heartbeat = new Proto.RemoteActivityWorkerHeartbeat + { + ActiveActivitiesCount = activeActivitiesCount, + }, + }; + } + + static Proto.RemoteActivityImage BuildImage(RemoteActivityOptions options) + { + if (!options.PublicPull) + { + throw new InvalidOperationException("Remote activity images must be publicly pullable for private preview."); + } + + string? imageRef = Coalesce( + options.ContainerImage, + BuildImageRef(options.RegistryServer, options.Repository, options.Tag, options.ImageDigest)); + + if (string.IsNullOrWhiteSpace(imageRef)) + { + throw new InvalidOperationException("Remote activity image metadata requires a container image reference."); + } + + return new Proto.RemoteActivityImage + { + ImageRef = imageRef, + PublicPull = true, + }; + } + + static Proto.SubstrateKind GetSubstrateFromEnvironment() + { + string? substrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); + if (substrate is null) + { + return Proto.SubstrateKind.Unspecified; + } + + if (substrate.Equals("Sandbox", StringComparison.OrdinalIgnoreCase)) + { + return Proto.SubstrateKind.Sandbox; + } + + if (substrate.Equals("AcaSessionPool", StringComparison.OrdinalIgnoreCase)) + { + return Proto.SubstrateKind.AcaSessionPool; + } + + return Proto.SubstrateKind.Unspecified; + } + + static string? BuildImageRef(string? registryServer, string? repository, string? tag, string? digest) + { + if (string.IsNullOrWhiteSpace(repository)) + { + return null; + } + + string image = string.IsNullOrWhiteSpace(registryServer) ? repository : $"{registryServer}/{repository}"; + if (!string.IsNullOrWhiteSpace(digest)) + { + return $"{image}@{digest}"; + } + + return string.IsNullOrWhiteSpace(tag) ? image : $"{image}:{tag}"; + } + + static string? Coalesce(params string?[] values) + { + foreach (string? value in values) + { + if (!string.IsNullOrWhiteSpace(value)) + { + return value.Trim(); + } + } + + return null; + } +} diff --git a/src/Worker/AzureManaged/Serverless/RemoteActivityDeclarationHostedService.cs b/src/Worker/AzureManaged/Serverless/RemoteActivityDeclarationHostedService.cs new file mode 100644 index 00000000..702d8993 --- /dev/null +++ b/src/Worker/AzureManaged/Serverless/RemoteActivityDeclarationHostedService.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Grpc.Core; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Proto = Microsoft.DurableTask.Protobuf.Serverless; + +namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; + +/// +/// Hosted service that declares remote activities with DTS when the local worker starts. +/// +sealed partial class RemoteActivityDeclarationHostedService : IHostedService +{ + readonly IServerlessActivitiesClient client; + readonly RemoteActivityOptions options; + readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The serverless activities client. + /// The remote activity options. + /// The logger. + public RemoteActivityDeclarationHostedService( + IServerlessActivitiesClient client, + RemoteActivityOptions options, + ILogger logger) + { + this.client = Check.NotNull(client); + this.options = Check.NotNull(options); + this.logger = Check.NotNull(logger); + } + + /// + /// Gets a task completed when the declaration attempt succeeds, is skipped, or fails. + /// + internal TaskCompletionSource Ready { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + string[] activityNames = RemoteActivityConfiguration.ResolveActivityNames(this.options.ActivityNames); + if (activityNames.Length == 0) + { + Log.NoRemoteActivitiesDiscovered(this.logger, this.options.TaskHub); + this.Ready.TrySetResult(null); + return; + } + + Proto.RemoteActivityDeclaration declaration = RemoteActivityConfiguration.BuildDeclaration(this.options, activityNames); + int maxAttempts = Math.Max(1, this.options.DeclarationRetryMaxAttempts); + for (int attempt = 1; ; attempt++) + { + try + { + Proto.RemoteActivityDeclarationResult result = await this.client.DeclareRemoteActivitiesAsync( + declaration, + cancellationToken).ConfigureAwait(false); + this.Ready.TrySetResult(result); + Log.RemoteActivitiesDeclared( + this.logger, + declaration.TaskHub, + declaration.WorkerProfileId, + declaration.ActivityNames.Count, + declaration.Image?.ImageRef ?? string.Empty); + return; + } + catch (Exception ex) when (IsTransient(ex) && attempt < maxAttempts && !cancellationToken.IsCancellationRequested) + { + Log.RemoteActivityDeclarationRetry(this.logger, ex, declaration.TaskHub, attempt, maxAttempts); + if (this.options.DeclarationRetryDelay > TimeSpan.Zero) + { + await Task.Delay(this.options.DeclarationRetryDelay, cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) + { + this.Ready.TrySetException(ex); + Log.RemoteActivityDeclarationFailed(this.logger, ex, declaration.TaskHub); + throw; + } + } + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + static bool IsTransient(Exception exception) => + exception is RpcException rpcException + && (rpcException.StatusCode == StatusCode.Unavailable + || rpcException.StatusCode == StatusCode.DeadlineExceeded + || rpcException.StatusCode == StatusCode.ResourceExhausted + || rpcException.StatusCode == StatusCode.Internal); + + static partial class Log + { + /// + /// Logs that no remote activities were discovered for declaration. + /// + /// The logger. + /// The task hub name. + [LoggerMessage( + EventId = 1, + Level = LogLevel.Information, + Message = "No remote activities discovered for hub={Hub}; skipping declaration")] + public static partial void NoRemoteActivitiesDiscovered(ILogger logger, string hub); + + /// + /// Logs a successful remote activity declaration. + /// + /// The logger. + /// The task hub name. + /// The worker profile ID. + /// The declared activity count. + /// The remote worker image reference. + [LoggerMessage( + EventId = 2, + Level = LogLevel.Information, + Message = "Remote activities declared hub={Hub} workerProfile={WorkerProfile} count={Count} image={Image}")] + public static partial void RemoteActivitiesDeclared( + ILogger logger, + string hub, + string workerProfile, + int count, + string image); + + /// + /// Logs a transient remote activity declaration failure that will be retried. + /// + /// The logger. + /// The transient exception. + /// The task hub name. + /// The current attempt number. + /// The maximum attempt count. + [LoggerMessage( + EventId = 3, + Level = LogLevel.Warning, + Message = "Remote activity declaration failed transiently hub={Hub} attempt={Attempt} maxAttempts={MaxAttempts}")] + public static partial void RemoteActivityDeclarationRetry( + ILogger logger, + Exception exception, + string hub, + int attempt, + int maxAttempts); + + /// + /// Logs a failed remote activity declaration. + /// + /// The logger. + /// The declaration exception. + /// The task hub name. + [LoggerMessage( + EventId = 4, + Level = LogLevel.Error, + Message = "Remote activity declaration failed hub={Hub}")] + public static partial void RemoteActivityDeclarationFailed(ILogger logger, Exception exception, string hub); + } +} diff --git a/src/Worker/AzureManaged/Serverless/RemoteActivityOptions.cs b/src/Worker/AzureManaged/Serverless/RemoteActivityOptions.cs new file mode 100644 index 00000000..4825c44b --- /dev/null +++ b/src/Worker/AzureManaged/Serverless/RemoteActivityOptions.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; + +/// +/// Options for declaring remote activities and the image DTS should use to run them. +/// +public sealed class RemoteActivityOptions +{ + /// + /// Default worker profile ID used when no profile is specified. + /// + internal const string DefaultWorkerProfileId = "default"; + + /// + /// Gets the remote activity names to declare. + /// + public IList ActivityNames { get; } = new List(); + + /// + /// Gets or sets the task hub that owns this declaration. + /// + public string TaskHub { get; set; } = string.Empty; + + /// + /// Gets or sets the full container image reference for the remote worker image. + /// + public string? ContainerImage { get; set; } + + /// + /// Gets or sets the registry server for the remote worker image. + /// + public string? RegistryServer { get; set; } + + /// + /// Gets or sets the repository for the remote worker image. + /// + public string? Repository { get; set; } + + /// + /// Gets or sets the tag for the remote worker image. + /// + public string? Tag { get; set; } + + /// + /// Gets or sets the digest for the remote worker image. + /// + public string? ImageDigest { get; set; } + + /// + /// Gets or sets a value indicating whether the image is publicly pullable. Private preview requires this to be true. + /// + public bool PublicPull { get; set; } = true; + + /// + /// Gets environment variables DTS should provide to remote workers created from this declaration. + /// + public IDictionary EnvironmentVariables { get; } = new Dictionary(StringComparer.Ordinal); + + /// + /// Gets or sets the maximum concurrent activities expected from each remote worker. + /// + public int MaxConcurrentActivities { get; set; } = 100; + + /// + /// Gets or sets the maximum number of declaration attempts made on transient failures. + /// + public int DeclarationRetryMaxAttempts { get; set; } = 5; + + /// + /// Gets or sets the delay between declaration retry attempts. + /// + public TimeSpan DeclarationRetryDelay { get; set; } = TimeSpan.FromSeconds(1); +} diff --git a/src/Worker/AzureManaged/Serverless/RemoteActivityWorkerOptions.cs b/src/Worker/AzureManaged/Serverless/RemoteActivityWorkerOptions.cs new file mode 100644 index 00000000..35e6e0c4 --- /dev/null +++ b/src/Worker/AzureManaged/Serverless/RemoteActivityWorkerOptions.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; + +/// +/// Options for a sandbox worker that registers live remote activity capacity with DTS. +/// +public sealed class RemoteActivityWorkerOptions +{ + /// + /// Gets the remote activity names this worker should execute. + /// + public IList ActivityNames { get; } = new List(); + + /// + /// Gets or sets the task hub this worker connects to. + /// + public string TaskHub { get; set; } = string.Empty; + + /// + /// Gets the unique worker instance identifier. + /// + public string WorkerInstanceId { get; } = Guid.NewGuid().ToString("N"); + + /// + /// Gets or sets the maximum number of concurrent activities this worker can accept. + /// + public int MaxConcurrentActivities { get; set; } = 100; + + /// + /// Gets or sets the interval used to refresh live worker capacity while the registration stream is open. + /// + public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(2); +} diff --git a/src/Worker/AzureManaged/Serverless/RemoteActivityWorkerRegistrationHostedService.cs b/src/Worker/AzureManaged/Serverless/RemoteActivityWorkerRegistrationHostedService.cs new file mode 100644 index 00000000..47ccfafc --- /dev/null +++ b/src/Worker/AzureManaged/Serverless/RemoteActivityWorkerRegistrationHostedService.cs @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Grpc.Core; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Proto = Microsoft.DurableTask.Protobuf.Serverless; + +namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; + +/// +/// Hosted service that registers a running process as a remote activity worker with DTS. +/// +sealed partial class RemoteActivityWorkerRegistrationHostedService : IHostedService, IAsyncDisposable +{ + readonly IServerlessActivitiesClient client; + readonly RemoteActivityWorkerOptions options; + readonly ILogger logger; + readonly IHostApplicationLifetime? lifetime; + CancellationTokenSource? cts; + IRemoteActivityWorkerSession? session; + Task? pump; + + /// + /// Initializes a new instance of the class. + /// + /// The serverless activities client. + /// The remote activity worker options. + /// The logger. + /// The optional application lifetime used to stop the host when the registration stream fails. + public RemoteActivityWorkerRegistrationHostedService( + IServerlessActivitiesClient client, + RemoteActivityWorkerOptions options, + ILogger logger, + IHostApplicationLifetime? lifetime = null) + { + this.client = Check.NotNull(client); + this.options = Check.NotNull(options); + this.logger = Check.NotNull(logger); + this.lifetime = lifetime; + } + + /// + /// Gets a task completed when the worker registration succeeds, is skipped, or fails. + /// + internal TaskCompletionSource Ready { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + string[] activityNames = RemoteActivityConfiguration.ResolveActivityNames(this.options.ActivityNames); + if (activityNames.Length == 0) + { + Log.NoRemoteActivitiesDiscovered(this.logger, this.options.TaskHub); + this.Ready.TrySetResult(true); + this.pump = Task.CompletedTask; + return; + } + + CancellationTokenSource registrationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + this.cts = registrationCts; + IRemoteActivityWorkerSession registrationSession = this.client.OpenRemoteActivityWorkerSession(registrationCts.Token); + this.session = registrationSession; + + Proto.RemoteActivityWorkerMessage startMessage = RemoteActivityConfiguration.BuildWorkerStart(this.options); + try + { + await registrationSession.WriteMessageAsync(startMessage).ConfigureAwait(false); + this.Ready.TrySetResult(true); + Log.RemoteActivityWorkerRegistered( + this.logger, + startMessage.Start.TaskHub, + startMessage.Start.WorkerInstanceId, + activityNames.Length, + startMessage.Start.Substrate, + startMessage.Start.SandboxId); + } + catch (Exception ex) + { + this.Ready.TrySetException(ex); + Log.RemoteActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); + throw; + } + + this.pump = Task.Run( + () => this.PumpHeartbeatsAsync(registrationSession, registrationCts.Token), + CancellationToken.None); + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + CancellationTokenSource? localCts = this.cts; + IRemoteActivityWorkerSession? localSession = this.session; + localCts?.Cancel(); + + if (localSession is not null) + { + try + { + await localSession.CompleteAsync().ConfigureAwait(false); + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or RpcException) + { + } + } + + if (this.pump is not null) + { + try + { + await this.pump.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or RpcException) + { + } + } + + if (localSession is not null) + { + await localSession.DisposeAsync().ConfigureAwait(false); + } + + localCts?.Dispose(); + if (ReferenceEquals(this.cts, localCts)) + { + this.cts = null; + } + + if (ReferenceEquals(this.session, localSession)) + { + this.session = null; + } + + this.pump = Task.CompletedTask; + } + + /// + public ValueTask DisposeAsync() => new(this.StopAsync(CancellationToken.None)); + + async Task PumpHeartbeatsAsync( + IRemoteActivityWorkerSession registrationSession, + CancellationToken cancellationToken) + { + try + { + using PeriodicTimer timer = new(this.options.HeartbeatInterval); + while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + { + await registrationSession.WriteMessageAsync( + RemoteActivityConfiguration.BuildWorkerHeartbeat(activeActivitiesCount: 0)).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + this.HandleRegistrationStreamFailure(ex); + } + } + + void HandleRegistrationStreamFailure(Exception exception) + { + Log.RemoteActivityWorkerRegistrationFailed(this.logger, exception, this.options.TaskHub); + this.lifetime?.StopApplication(); + } + + static partial class Log + { + /// + /// Logs that no remote activities were discovered for live worker registration. + /// + /// The logger. + /// The task hub name. + [LoggerMessage( + EventId = 1, + Level = LogLevel.Information, + Message = "No remote activities discovered for worker hub={Hub}; skipping live registration")] + public static partial void NoRemoteActivitiesDiscovered(ILogger logger, string hub); + + /// + /// Logs a successful remote activity worker registration. + /// + /// The logger. + /// The task hub name. + /// The worker instance ID. + /// The activity count. + /// The substrate kind. + /// The sandbox ID. + [LoggerMessage( + EventId = 2, + Level = LogLevel.Information, + Message = "Remote activity worker registered hub={Hub} worker={Worker} count={Count} substrate={Substrate} sandboxId={SandboxId}")] + public static partial void RemoteActivityWorkerRegistered( + ILogger logger, + string hub, + string worker, + int count, + Proto.SubstrateKind substrate, + string sandboxId); + + /// + /// Logs a failed remote activity worker registration stream. + /// + /// The logger. + /// The registration exception. + /// The task hub name. + [LoggerMessage( + EventId = 3, + Level = LogLevel.Error, + Message = "Remote activity worker registration stream failed hub={Hub}")] + public static partial void RemoteActivityWorkerRegistrationFailed(ILogger logger, Exception exception, string hub); + } +} diff --git a/src/Worker/AzureManaged/Serverless/ServerlessActivitiesClientAdapter.cs b/src/Worker/AzureManaged/Serverless/ServerlessActivitiesClientAdapter.cs new file mode 100644 index 00000000..27ed6bb7 --- /dev/null +++ b/src/Worker/AzureManaged/Serverless/ServerlessActivitiesClientAdapter.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Grpc.Core; +using Proto = Microsoft.DurableTask.Protobuf.Serverless; + +namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; + +/// +/// Client abstraction for the serverless activities gRPC service. +/// +interface IServerlessActivitiesClient +{ + /// + /// Declares remote activities to DTS. + /// + /// The declaration message. + /// The cancellation token. + /// The declaration result. + Task DeclareRemoteActivitiesAsync( + Proto.RemoteActivityDeclaration declaration, + CancellationToken cancellationToken); + + /// + /// Opens a remote activity worker registration session. + /// + /// The cancellation token. + /// The worker registration session. + IRemoteActivityWorkerSession OpenRemoteActivityWorkerSession(CancellationToken cancellationToken); +} + +/// +/// Client-streaming session used by a remote activity worker registration. +/// +interface IRemoteActivityWorkerSession : IAsyncDisposable +{ + /// + /// Writes a worker registration message to the stream. + /// + /// The message to write. + /// A task that completes when the message is written. + Task WriteMessageAsync(Proto.RemoteActivityWorkerMessage message); + + /// + /// Completes the request stream. + /// + /// A task that completes when the stream is completed. + Task CompleteAsync(); +} + +/// +/// gRPC-backed implementation of . +/// +sealed class ServerlessActivitiesClientAdapter : IServerlessActivitiesClient +{ + readonly Proto.ServerlessActivities.ServerlessActivitiesClient client; + + /// + /// Initializes a new instance of the class. + /// + /// The generated serverless activities gRPC client. + public ServerlessActivitiesClientAdapter(Proto.ServerlessActivities.ServerlessActivitiesClient client) + { + this.client = Check.NotNull(client); + } + + /// + public async Task DeclareRemoteActivitiesAsync( + Proto.RemoteActivityDeclaration declaration, + CancellationToken cancellationToken) + { + return await this.client.DeclareRemoteActivitiesAsync(declaration, cancellationToken: cancellationToken) + .ResponseAsync.ConfigureAwait(false); + } + + /// + public IRemoteActivityWorkerSession OpenRemoteActivityWorkerSession(CancellationToken cancellationToken) + { + AsyncClientStreamingCall call = + this.client.ConnectRemoteActivityWorker(cancellationToken: cancellationToken); + return new GrpcRemoteActivityWorkerSession(call); + } + + /// + /// gRPC-backed remote activity worker registration session. + /// + sealed class GrpcRemoteActivityWorkerSession : IRemoteActivityWorkerSession + { + readonly AsyncClientStreamingCall call; + + /// + /// Initializes a new instance of the class. + /// + /// The active gRPC client-streaming call. + public GrpcRemoteActivityWorkerSession(AsyncClientStreamingCall call) + { + this.call = call; + } + + /// + public Task WriteMessageAsync(Proto.RemoteActivityWorkerMessage message) => + this.call.RequestStream.WriteAsync(message); + + /// + public Task CompleteAsync() => this.call.RequestStream.CompleteAsync(); + + /// + public ValueTask DisposeAsync() + { + this.call.Dispose(); + return default; + } + } +} diff --git a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs index a9274078..50e967ae 100644 --- a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs @@ -171,12 +171,14 @@ public static IDurableTaskWorkerBuilder UseWorkItemFilters(this IDurableTaskWork { opts.Orchestrations = []; opts.Activities = []; + opts.ExcludedActivities = []; opts.Entities = []; } else { opts.Orchestrations = workItemFilters.Orchestrations; opts.Activities = workItemFilters.Activities; + opts.ExcludedActivities = workItemFilters.ExcludedActivities; opts.Entities = workItemFilters.Entities; } }); @@ -194,6 +196,7 @@ public static IDurableTaskWorkerBuilder UseWorkItemFilters(this IDurableTaskWork if (workItemFilters is not null && (workItemFilters.Orchestrations.Count > 0 || workItemFilters.Activities.Count > 0 + || workItemFilters.ExcludedActivities.Count > 0 || workItemFilters.Entities.Count > 0)) { builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< diff --git a/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs index c8eefb12..bcb45212 100644 --- a/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs @@ -42,6 +42,7 @@ public ValidateOptionsResult Validate(string? name, DurableTaskWorkerWorkItemFil // reports a verdict for workers that actually configured filters. if (options.Orchestrations.Count == 0 && options.Activities.Count == 0 + && options.ExcludedActivities.Count == 0 && options.Entities.Count == 0) { return ValidateOptionsResult.Skip; @@ -53,11 +54,14 @@ public ValidateOptionsResult Validate(string? name, DurableTaskWorkerWorkItemFil options.Orchestrations.Select(o => o.Name), n => registry.Orchestrators.ContainsKey(n)); List unknownActivities = FindUnknown( options.Activities.Select(a => a.Name), n => registry.Activities.ContainsKey(n)); + List unknownExcludedActivities = FindUnknown( + options.ExcludedActivities.Select(a => a.Name), n => registry.Activities.ContainsKey(n)); List unknownEntities = FindUnknown( options.Entities.Select(e => e.Name), n => registry.Entities.ContainsKey(n)); if (unknownOrchestrations.Count == 0 && unknownActivities.Count == 0 + && unknownExcludedActivities.Count == 0 && unknownEntities.Count == 0) { return ValidateOptionsResult.Success; @@ -71,6 +75,7 @@ public ValidateOptionsResult Validate(string? name, DurableTaskWorkerWorkItemFil .Append("or remove them from the filters."); AppendCategory(sb, "Orchestrations", unknownOrchestrations); AppendCategory(sb, "Activities", unknownActivities); + AppendCategory(sb, "ExcludedActivities", unknownExcludedActivities); AppendCategory(sb, "Entities", unknownEntities); return ValidateOptionsResult.Fail(sb.ToString()); diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index 8a5df2f1..ec24fd2e 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -22,6 +22,11 @@ public class DurableTaskWorkerWorkItemFilters /// public IReadOnlyList Activities { get; set; } = []; + /// + /// Gets or sets the activity filters that should be excluded from this worker connection. + /// + public IReadOnlyList ExcludedActivities { get; set; } = []; + /// /// Gets or sets the entity filters. /// @@ -55,6 +60,7 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable Name = activity.Key, Versions = versions, }).ToList(), + ExcludedActivities = [], Entities = registry.Entities.Select(entity => new EntityFilter { // Entity names are normalized to lowercase in the backend. diff --git a/src/Worker/Grpc/Internal/DurableTaskWorkerWorkItemFiltersExtension.cs b/src/Worker/Grpc/Internal/DurableTaskWorkerWorkItemFiltersExtension.cs index 176d376c..63c2b052 100644 --- a/src/Worker/Grpc/Internal/DurableTaskWorkerWorkItemFiltersExtension.cs +++ b/src/Worker/Grpc/Internal/DurableTaskWorkerWorkItemFiltersExtension.cs @@ -39,6 +39,16 @@ public static P.WorkItemFilters ToGrpcWorkItemFilters(this DurableTaskWorkerWork grpcWorkItemFilters.Activities.Add(grpcActivityFilter); } + foreach (var activityFilter in workItemFilter.ExcludedActivities) + { + var grpcActivityFilter = new P.ActivityFilter + { + Name = activityFilter.Name, + }; + grpcActivityFilter.Versions.AddRange(activityFilter.Versions); + grpcWorkItemFilters.ExcludeActivities.Add(grpcActivityFilter); + } + foreach (var entityFilter in workItemFilter.Entities) { var grpcEntityFilter = new P.EntityFilter diff --git a/test/Worker/AzureManaged.Tests/RemoteActivitiesTests.cs b/test/Worker/AzureManaged.Tests/RemoteActivitiesTests.cs new file mode 100644 index 00000000..1c749a94 --- /dev/null +++ b/test/Worker/AzureManaged.Tests/RemoteActivitiesTests.cs @@ -0,0 +1,325 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Grpc.Core; +using Microsoft.DurableTask.Protobuf.Serverless; +using Microsoft.DurableTask.Worker.AzureManaged.Serverless; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Microsoft.DurableTask.Worker.AzureManaged.Tests; + +public class RemoteActivitiesTests +{ + const string TaskHub = "testhub"; + + [Fact] + public async Task RemoteActivityDeclarationHostedService_SendsDeclarationPayload() + { + // Arrange + RemoteActivityOptions options = new() + { + TaskHub = TaskHub, + ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", + MaxConcurrentActivities = 7, + }; + options.ActivityNames.Add("RemoteHello"); + options.EnvironmentVariables.Add("CUSTOM_SETTING", "enabled"); + FakeServerlessActivitiesClient client = new(); + RemoteActivityDeclarationHostedService service = new( + client, + options, + NullLogger.Instance); + + // Act + await service.StartAsync(CancellationToken.None); + + // Assert + RemoteActivityDeclaration declaration = client.Declarations.Should().ContainSingle().Subject; + declaration.TaskHub.Should().Be(TaskHub); + declaration.ActivityNames.Should().Equal("RemoteHello"); + declaration.Image.ImageRef.Should().Be("mcr.microsoft.com/durabletask/demo-worker:1.0"); + declaration.Image.PublicPull.Should().BeTrue(); + declaration.EnvironmentVariables.Should().ContainKey("CUSTOM_SETTING").WhoseValue.Should().Be("enabled"); + declaration.MaxConcurrentActivities.Should().Be(7); + } + + [Fact] + public async Task RemoteActivityDeclarationHostedService_SkipsDeclarationWhenNamesAreEmpty() + { + // Arrange + RemoteActivityOptions options = new() + { + TaskHub = TaskHub, + ContainerImage = "example.com/repo/worker:latest", + }; + FakeServerlessActivitiesClient client = new(); + RemoteActivityDeclarationHostedService service = new( + client, + options, + NullLogger.Instance); + + // Act + await service.StartAsync(CancellationToken.None); + + // Assert + client.Declarations.Should().BeEmpty(); + } + + [Fact] + public async Task RemoteActivityDeclarationHostedService_RetriesTransientFailures() + { + // Arrange + RemoteActivityOptions options = new() + { + TaskHub = TaskHub, + ContainerImage = "example.com/repo/worker@sha256:abc", + DeclarationRetryMaxAttempts = 2, + DeclarationRetryDelay = TimeSpan.Zero, + }; + options.ActivityNames.Add("RemoteHello"); + FakeServerlessActivitiesClient client = new() { TransientDeclarationFailures = 1 }; + RemoteActivityDeclarationHostedService service = new( + client, + options, + NullLogger.Instance); + + // Act + await service.StartAsync(CancellationToken.None); + + // Assert + client.DeclarationAttempts.Should().Be(2); + client.Declarations.Should().ContainSingle(); + } + + [Fact] + public async Task RemoteActivityDeclarationHostedService_RejectsPrivatePullImages() + { + // Arrange + RemoteActivityOptions options = new() + { + TaskHub = TaskHub, + ContainerImage = "example.com/repo/worker:latest", + PublicPull = false, + }; + options.ActivityNames.Add("RemoteHello"); + RemoteActivityDeclarationHostedService service = new( + new FakeServerlessActivitiesClient(), + options, + NullLogger.Instance); + + // Act + Func action = () => service.StartAsync(CancellationToken.None); + + // Assert + await action.Should().ThrowAsync() + .WithMessage("Remote activity images must be publicly pullable for private preview."); + } + + [Fact] + public async Task RemoteActivityWorkerRegistrationHostedService_SendsLiveWorkerMetadataWithoutActivityCatalog() + { + // Arrange + string? originalSubstrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); + string? originalSandboxId = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID"); + Environment.SetEnvironmentVariable("DTS_SUBSTRATE", "Sandbox"); + Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", "sandbox-1"); + + try + { + RemoteActivityWorkerOptions options = new() + { + TaskHub = TaskHub, + MaxConcurrentActivities = 3, + HeartbeatInterval = TimeSpan.FromDays(1), + }; + options.ActivityNames.Add("RemoteHello"); + FakeServerlessActivitiesClient client = new(); + RemoteActivityWorkerRegistrationHostedService service = new( + client, + options, + NullLogger.Instance); + + // Act + await service.StartAsync(CancellationToken.None); + await service.StopAsync(CancellationToken.None); + + // Assert + RemoteActivityWorkerMessage message = client.Session.Messages.Should().ContainSingle().Subject; + RemoteActivityWorkerStart start = message.Start; + start.TaskHub.Should().Be(TaskHub); + start.MaxActivitiesCount.Should().Be(3); + start.Substrate.Should().Be(SubstrateKind.Sandbox); + start.SandboxId.Should().Be("sandbox-1"); + } + finally + { + Environment.SetEnvironmentVariable("DTS_SUBSTRATE", originalSubstrate); + Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", originalSandboxId); + } + } + + [Fact] + public async Task DeclareRemoteActivities_ConfiguresLocalWorkerExclusionFilter() + { + // Arrange + using EnvironmentVariableScope remoteActivities = new("DTS_REMOTE_ACTIVITIES", null); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(b => b.Services).Returns(services); + mockBuilder.Setup(b => b.Name).Returns(Options.DefaultName); + + // Act + mockBuilder.Object.DeclareRemoteActivities(options => + { + options.TaskHub = TaskHub; + options.ContainerImage = "example.com/repo/worker:latest"; + options.ActivityNames.Add("RemoteHello"); + }); + + await using ServiceProvider provider = services.BuildServiceProvider(); + DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); + + // Assert + filters.ExcludedActivities.Select(filter => filter.Name).Should().Equal("RemoteHello"); + filters.Activities.Should().BeEmpty(); + } + + [Fact] + public async Task DeclareRemoteActivities_DoesNotConfigureFilterWhenActivityNamesAreEmpty() + { + // Arrange + using EnvironmentVariableScope remoteActivities = new("DTS_REMOTE_ACTIVITIES", null); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + // Act + mockBuilder.Object.DeclareRemoteActivities(options => + { + options.TaskHub = TaskHub; + options.ContainerImage = "example.com/repo/worker:latest"; + }); + + await using ServiceProvider provider = services.BuildServiceProvider(); + DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); + + // Assert + filters.ExcludedActivities.Should().BeEmpty(); + filters.Activities.Should().BeEmpty(); + } + + [Fact] + public async Task UseRemoteActivityWorker_ConfiguresRemoteActivityWorkerFilter() + { + // Arrange + using EnvironmentVariableScope remoteActivities = new("DTS_REMOTE_ACTIVITIES", null); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(b => b.Services).Returns(services); + mockBuilder.Setup(b => b.Name).Returns(Options.DefaultName); + + // Act + mockBuilder.Object.UseRemoteActivityWorker(options => + { + options.TaskHub = TaskHub; + options.ActivityNames.Add("RemoteHello"); + }); + + await using ServiceProvider provider = services.BuildServiceProvider(); + DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); + + // Assert + filters.Activities.Select(filter => filter.Name).Should().Equal("RemoteHello"); + filters.ExcludedActivities.Should().BeEmpty(); + filters.Orchestrations.Should().BeEmpty(); + filters.Entities.Should().BeEmpty(); + } + + [Fact] + public async Task UseRemoteActivityWorker_DoesNotConfigureFilterWhenActivityNamesAreEmpty() + { + // Arrange + using EnvironmentVariableScope remoteActivities = new("DTS_REMOTE_ACTIVITIES", null); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + // Act + mockBuilder.Object.UseRemoteActivityWorker(options => + { + options.TaskHub = TaskHub; + }); + + await using ServiceProvider provider = services.BuildServiceProvider(); + DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); + + // Assert + filters.Activities.Should().BeEmpty(); + filters.ExcludedActivities.Should().BeEmpty(); + } + + sealed class FakeServerlessActivitiesClient : IServerlessActivitiesClient + { + public int TransientDeclarationFailures { get; init; } + + public int DeclarationAttempts { get; private set; } + + public List Declarations { get; } = []; + + public FakeRemoteActivityWorkerSession Session { get; } = new(); + + public Task DeclareRemoteActivitiesAsync( + RemoteActivityDeclaration declaration, + CancellationToken cancellationToken) + { + this.DeclarationAttempts++; + if (this.DeclarationAttempts <= this.TransientDeclarationFailures) + { + throw new RpcException(new Status(StatusCode.Unavailable, "transient")); + } + + this.Declarations.Add(declaration.Clone()); + return Task.FromResult(new RemoteActivityDeclarationResult()); + } + + public IRemoteActivityWorkerSession OpenRemoteActivityWorkerSession(CancellationToken cancellationToken) => this.Session; + } + + sealed class FakeRemoteActivityWorkerSession : IRemoteActivityWorkerSession + { + public List Messages { get; } = []; + + public Task WriteMessageAsync(RemoteActivityWorkerMessage message) + { + this.Messages.Add(message.Clone()); + return Task.CompletedTask; + } + + public Task CompleteAsync() => Task.CompletedTask; + + public ValueTask DisposeAsync() => default; + } + + sealed class EnvironmentVariableScope : IDisposable + { + readonly string name; + readonly string? originalValue; + + public EnvironmentVariableScope(string name, string? value) + { + this.name = name; + this.originalValue = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() => Environment.SetEnvironmentVariable(this.name, this.originalValue); + } +} \ No newline at end of file From 05223b6cbc34554e22eaf53a3430baebfb4d923f Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 13 May 2026 19:52:22 -0700 Subject: [PATCH 02/81] Add remote activity worker profiles --- src/Grpc/serverless_activities_service.proto | 1 + .../DurableTaskSchedulerWorkerExtensions.cs | 11 +++++++++++ .../Serverless/RemoteActivityConfiguration.cs | 17 ++++++++++++++++- .../Serverless/RemoteActivityOptions.cs | 5 +++++ .../Serverless/RemoteActivityWorkerOptions.cs | 5 +++++ .../AzureManaged.Tests/RemoteActivitiesTests.cs | 4 ++++ 6 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/Grpc/serverless_activities_service.proto b/src/Grpc/serverless_activities_service.proto index ee3bbb79..a590427f 100644 --- a/src/Grpc/serverless_activities_service.proto +++ b/src/Grpc/serverless_activities_service.proto @@ -33,6 +33,7 @@ message RemoteActivityWorkerStart { SubstrateKind substrate = 4; // Identifier of the ADC sandbox the worker is running inside. Empty when substrate != SANDBOX. string sandbox_id = 5; + string worker_profile_id = 6; } message RemoteActivityWorkerHeartbeat { diff --git a/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs b/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs index c25bbf10..55a427ea 100644 --- a/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs +++ b/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs @@ -249,6 +249,7 @@ static void ApplyTaskHubDefault(RemoteActivityWorkerOptions options, string task static void ApplyRemoteActivityEnvironmentOverrides(RemoteActivityOptions options) { ApplyActivityNameEnvironmentOverride(options.ActivityNames); + ApplyWorkerProfileEnvironmentOverride(profile => options.WorkerProfileId = profile); string? image = Environment.GetEnvironmentVariable("DTS_REMOTE_ACTIVITY_IMAGE"); if (!string.IsNullOrWhiteSpace(image)) @@ -260,6 +261,7 @@ static void ApplyRemoteActivityEnvironmentOverrides(RemoteActivityOptions option static void ApplyRemoteActivityWorkerEnvironmentOverrides(RemoteActivityWorkerOptions options) { ApplyActivityNameEnvironmentOverride(options.ActivityNames); + ApplyWorkerProfileEnvironmentOverride(profile => options.WorkerProfileId = profile); if (int.TryParse(Environment.GetEnvironmentVariable("DTS_SERVERLESS_MAX_ACTIVITIES"), out int maxActivities) && maxActivities > 0) { @@ -284,6 +286,15 @@ static void ApplyActivityNameEnvironmentOverride(ICollection activityNam } } + static void ApplyWorkerProfileEnvironmentOverride(Action setWorkerProfileId) + { + string? workerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID"); + if (!string.IsNullOrWhiteSpace(workerProfileId)) + { + setWorkerProfileId(workerProfileId.Trim()); + } + } + static DurableTaskWorkerWorkItemFilters.ActivityFilter[] MergeActivityFilters( IReadOnlyList existingFilters, IEnumerable activityNames) diff --git a/src/Worker/AzureManaged/Serverless/RemoteActivityConfiguration.cs b/src/Worker/AzureManaged/Serverless/RemoteActivityConfiguration.cs index 528bbd1f..3c18bf3f 100644 --- a/src/Worker/AzureManaged/Serverless/RemoteActivityConfiguration.cs +++ b/src/Worker/AzureManaged/Serverless/RemoteActivityConfiguration.cs @@ -45,6 +45,8 @@ public static Proto.RemoteActivityDeclaration BuildDeclaration(RemoteActivityOpt throw new InvalidOperationException("Remote activity declaration requires at least one activity name."); } + string workerProfileId = NormalizeWorkerProfileId(options.WorkerProfileId, "Remote activity declaration requires a worker profile ID."); + if (options.MaxConcurrentActivities <= 0) { throw new InvalidOperationException("Remote activity max concurrent activities must be greater than zero."); @@ -53,7 +55,7 @@ public static Proto.RemoteActivityDeclaration BuildDeclaration(RemoteActivityOpt Proto.RemoteActivityDeclaration declaration = new() { TaskHub = options.TaskHub, - WorkerProfileId = RemoteActivityOptions.DefaultWorkerProfileId, + WorkerProfileId = workerProfileId, Image = BuildImage(options), MaxConcurrentActivities = options.MaxConcurrentActivities, }; @@ -82,9 +84,12 @@ public static Proto.RemoteActivityWorkerMessage BuildWorkerStart(RemoteActivityW throw new InvalidOperationException("Remote activity worker max concurrent activities must be greater than zero."); } + string workerProfileId = NormalizeWorkerProfileId(options.WorkerProfileId, "Remote activity worker registration requires a worker profile ID."); + Proto.RemoteActivityWorkerStart start = new() { TaskHub = options.TaskHub, + WorkerProfileId = workerProfileId, WorkerInstanceId = options.WorkerInstanceId, MaxActivitiesCount = options.MaxConcurrentActivities, Substrate = GetSubstrateFromEnvironment(), @@ -159,6 +164,16 @@ static Proto.SubstrateKind GetSubstrateFromEnvironment() return Proto.SubstrateKind.Unspecified; } + static string NormalizeWorkerProfileId(string value, string errorMessage) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException(errorMessage); + } + + return value.Trim(); + } + static string? BuildImageRef(string? registryServer, string? repository, string? tag, string? digest) { if (string.IsNullOrWhiteSpace(repository)) diff --git a/src/Worker/AzureManaged/Serverless/RemoteActivityOptions.cs b/src/Worker/AzureManaged/Serverless/RemoteActivityOptions.cs index 4825c44b..814d6621 100644 --- a/src/Worker/AzureManaged/Serverless/RemoteActivityOptions.cs +++ b/src/Worker/AzureManaged/Serverless/RemoteActivityOptions.cs @@ -23,6 +23,11 @@ public sealed class RemoteActivityOptions /// public string TaskHub { get; set; } = string.Empty; + /// + /// Gets or sets the worker profile ID that owns this remote activity declaration. + /// + public string WorkerProfileId { get; set; } = DefaultWorkerProfileId; + /// /// Gets or sets the full container image reference for the remote worker image. /// diff --git a/src/Worker/AzureManaged/Serverless/RemoteActivityWorkerOptions.cs b/src/Worker/AzureManaged/Serverless/RemoteActivityWorkerOptions.cs index 35e6e0c4..ee8bf78d 100644 --- a/src/Worker/AzureManaged/Serverless/RemoteActivityWorkerOptions.cs +++ b/src/Worker/AzureManaged/Serverless/RemoteActivityWorkerOptions.cs @@ -18,6 +18,11 @@ public sealed class RemoteActivityWorkerOptions /// public string TaskHub { get; set; } = string.Empty; + /// + /// Gets or sets the worker profile ID this worker registers capacity for. + /// + public string WorkerProfileId { get; set; } = RemoteActivityOptions.DefaultWorkerProfileId; + /// /// Gets the unique worker instance identifier. /// diff --git a/test/Worker/AzureManaged.Tests/RemoteActivitiesTests.cs b/test/Worker/AzureManaged.Tests/RemoteActivitiesTests.cs index 1c749a94..4af28d36 100644 --- a/test/Worker/AzureManaged.Tests/RemoteActivitiesTests.cs +++ b/test/Worker/AzureManaged.Tests/RemoteActivitiesTests.cs @@ -25,6 +25,7 @@ public async Task RemoteActivityDeclarationHostedService_SendsDeclarationPayload RemoteActivityOptions options = new() { TaskHub = TaskHub, + WorkerProfileId = "profile-a", ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", MaxConcurrentActivities = 7, }; @@ -42,6 +43,7 @@ public async Task RemoteActivityDeclarationHostedService_SendsDeclarationPayload // Assert RemoteActivityDeclaration declaration = client.Declarations.Should().ContainSingle().Subject; declaration.TaskHub.Should().Be(TaskHub); + declaration.WorkerProfileId.Should().Be("profile-a"); declaration.ActivityNames.Should().Equal("RemoteHello"); declaration.Image.ImageRef.Should().Be("mcr.microsoft.com/durabletask/demo-worker:1.0"); declaration.Image.PublicPull.Should().BeTrue(); @@ -135,6 +137,7 @@ public async Task RemoteActivityWorkerRegistrationHostedService_SendsLiveWorkerM RemoteActivityWorkerOptions options = new() { TaskHub = TaskHub, + WorkerProfileId = "profile-a", MaxConcurrentActivities = 3, HeartbeatInterval = TimeSpan.FromDays(1), }; @@ -153,6 +156,7 @@ public async Task RemoteActivityWorkerRegistrationHostedService_SendsLiveWorkerM RemoteActivityWorkerMessage message = client.Session.Messages.Should().ContainSingle().Subject; RemoteActivityWorkerStart start = message.Start; start.TaskHub.Should().Be(TaskHub); + start.WorkerProfileId.Should().Be("profile-a"); start.MaxActivitiesCount.Should().Be(3); start.Substrate.Should().Be(SubstrateKind.Sandbox); start.SandboxId.Should().Be("sandbox-1"); From ce8c38c567f8cde4e44b5e4573b444d4a8f76f24 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 14 May 2026 18:18:12 -0700 Subject: [PATCH 03/81] serverless pkg --- Microsoft.DurableTask.sln | 46 +++- .../AzureManagedServerless.csproj | 24 ++ .../ServerlessActivitiesClientExtensions.cs | 122 ++++++++++ .../Client/ServerlessSandboxLogLine.cs | 21 ++ ...TaskSchedulerServerlessWorkerExtensions.cs | 215 ++++++++++++++++++ .../ServerlessActivitiesClientAdapter.cs | 51 +++-- .../ServerlessActivityConfiguration.cs} | 97 +++++--- ...erlessActivityDeclarationHostedService.cs} | 71 +++--- ...ctivityWorkerRegistrationHostedService.cs} | 65 +++--- .../Worker/Serverless/ServerlessOptions.cs | 136 +++++++++++ src/Grpc/serverless_activities_service.proto | 58 +++-- .../DurableTaskSchedulerWorkerExtensions.cs | 213 ----------------- .../Serverless/RemoteActivityOptions.cs | 80 ------- .../Serverless/RemoteActivityWorkerOptions.cs | 40 ---- .../AzureManagedServerless.Tests.csproj | 16 ++ ...rverlessActivitiesClientExtensionsTests.cs | 189 +++++++++++++++ .../ServerlessActivitiesTests.cs} | 145 ++++++------ 17 files changed, 1049 insertions(+), 540 deletions(-) create mode 100644 src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj create mode 100644 src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs create mode 100644 src/Extensions/AzureManagedServerless/Client/ServerlessSandboxLogLine.cs create mode 100644 src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs rename src/{Worker/AzureManaged => Extensions/AzureManagedServerless/Worker}/Serverless/ServerlessActivitiesClientAdapter.cs (52%) rename src/{Worker/AzureManaged/Serverless/RemoteActivityConfiguration.cs => Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs} (56%) rename src/{Worker/AzureManaged/Serverless/RemoteActivityDeclarationHostedService.cs => Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs} (60%) rename src/{Worker/AzureManaged/Serverless/RemoteActivityWorkerRegistrationHostedService.cs => Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs} (67%) create mode 100644 src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs delete mode 100644 src/Worker/AzureManaged/Serverless/RemoteActivityOptions.cs delete mode 100644 src/Worker/AzureManaged/Serverless/RemoteActivityWorkerOptions.cs create mode 100644 test/Extensions/AzureManagedServerless.Tests/AzureManagedServerless.Tests.csproj create mode 100644 test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs rename test/{Worker/AzureManaged.Tests/RemoteActivitiesTests.cs => Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs} (63%) diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index 0b8ef935..467ee762 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.3.32901.215 @@ -115,6 +115,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NamespaceGenerationSample", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReplaySafeLoggerFactorySample", "samples\ReplaySafeLoggerFactorySample\ReplaySafeLoggerFactorySample.csproj", "{8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{21303FBF-2A2B-17C2-D2DF-3E924022E940}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureManagedServerless", "src\Extensions\AzureManagedServerless\AzureManagedServerless.csproj", "{C6DC28DC-95CE-42DA-B02C-FFB2BA1CB1A2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{D4587EC0-1B16-8420-7502-A967139249D4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{53193780-CD18-2643-6953-C26F59EAEDF5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{00205C88-F000-28F2-A910-C6FA00E065EE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureManagedServerless.Tests", "test\Extensions\AzureManagedServerless.Tests\AzureManagedServerless.Tests.csproj", "{4D50F5B2-4782-486F-A9AA-073D798CC60D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -701,7 +713,30 @@ Global {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Release|x64.Build.0 = Release|Any CPU {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Release|x86.ActiveCfg = Release|Any CPU {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4}.Release|x86.Build.0 = Release|Any CPU - + {C6DC28DC-95CE-42DA-B02C-FFB2BA1CB1A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C6DC28DC-95CE-42DA-B02C-FFB2BA1CB1A2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6DC28DC-95CE-42DA-B02C-FFB2BA1CB1A2}.Debug|x64.ActiveCfg = Debug|Any CPU + {C6DC28DC-95CE-42DA-B02C-FFB2BA1CB1A2}.Debug|x64.Build.0 = Debug|Any CPU + {C6DC28DC-95CE-42DA-B02C-FFB2BA1CB1A2}.Debug|x86.ActiveCfg = Debug|Any CPU + {C6DC28DC-95CE-42DA-B02C-FFB2BA1CB1A2}.Debug|x86.Build.0 = Debug|Any CPU + {C6DC28DC-95CE-42DA-B02C-FFB2BA1CB1A2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C6DC28DC-95CE-42DA-B02C-FFB2BA1CB1A2}.Release|Any CPU.Build.0 = Release|Any CPU + {C6DC28DC-95CE-42DA-B02C-FFB2BA1CB1A2}.Release|x64.ActiveCfg = Release|Any CPU + {C6DC28DC-95CE-42DA-B02C-FFB2BA1CB1A2}.Release|x64.Build.0 = Release|Any CPU + {C6DC28DC-95CE-42DA-B02C-FFB2BA1CB1A2}.Release|x86.ActiveCfg = Release|Any CPU + {C6DC28DC-95CE-42DA-B02C-FFB2BA1CB1A2}.Release|x86.Build.0 = Release|Any CPU + {4D50F5B2-4782-486F-A9AA-073D798CC60D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4D50F5B2-4782-486F-A9AA-073D798CC60D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D50F5B2-4782-486F-A9AA-073D798CC60D}.Debug|x64.ActiveCfg = Debug|Any CPU + {4D50F5B2-4782-486F-A9AA-073D798CC60D}.Debug|x64.Build.0 = Debug|Any CPU + {4D50F5B2-4782-486F-A9AA-073D798CC60D}.Debug|x86.ActiveCfg = Debug|Any CPU + {4D50F5B2-4782-486F-A9AA-073D798CC60D}.Debug|x86.Build.0 = Debug|Any CPU + {4D50F5B2-4782-486F-A9AA-073D798CC60D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D50F5B2-4782-486F-A9AA-073D798CC60D}.Release|Any CPU.Build.0 = Release|Any CPU + {4D50F5B2-4782-486F-A9AA-073D798CC60D}.Release|x64.ActiveCfg = Release|Any CPU + {4D50F5B2-4782-486F-A9AA-073D798CC60D}.Release|x64.Build.0 = Release|Any CPU + {4D50F5B2-4782-486F-A9AA-073D798CC60D}.Release|x86.ActiveCfg = Release|Any CPU + {4D50F5B2-4782-486F-A9AA-073D798CC60D}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -759,7 +794,12 @@ Global {4A7305AE-AAAE-43AE-AAB2-DA58DACC6FA8} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {5A69FD28-D814-490E-A76B-B0A5F88C25B2} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} - + {21303FBF-2A2B-17C2-D2DF-3E924022E940} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B} + {C6DC28DC-95CE-42DA-B02C-FFB2BA1CB1A2} = {21303FBF-2A2B-17C2-D2DF-3E924022E940} + {D4587EC0-1B16-8420-7502-A967139249D4} = {1C217BB2-CE16-41CC-9D47-0FC0DB60BDB3} + {53193780-CD18-2643-6953-C26F59EAEDF5} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5} + {00205C88-F000-28F2-A910-C6FA00E065EE} = {E5637F81-2FB9-4CD7-900D-455363B142A7} + {4D50F5B2-4782-486F-A9AA-073D798CC60D} = {00205C88-F000-28F2-A910-C6FA00E065EE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71} diff --git a/src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj b/src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj new file mode 100644 index 00000000..a8e8fed6 --- /dev/null +++ b/src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj @@ -0,0 +1,24 @@ + + + + net6.0;net8.0;net10.0 + Azure Managed serverless activities support for Durable Task. + Microsoft.DurableTask.AzureManaged.Serverless + true + + + + + + + + + + + + + + + + + diff --git a/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs b/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs new file mode 100644 index 00000000..96940740 --- /dev/null +++ b/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Proto = Microsoft.DurableTask.Protobuf.Serverless; + +namespace Microsoft.DurableTask.Client.AzureManaged; + +/// +/// Extension methods for the generated serverless activities gRPC client. +/// +public static class ServerlessActivitiesClientExtensions +{ + const int MinTail = 0; + const int MaxTail = 300; + + /// + /// Streams logs from a serverless activity sandbox using task hub metadata already configured on the gRPC channel. + /// + /// The generated serverless activities gRPC client. + /// The sandbox ID to stream logs from. + /// The number of historical log lines to include before streaming live logs. Must be between 0 and 300. + /// The cancellation token used to stop streaming. + /// An async stream of sandbox log lines. + public static IAsyncEnumerable StreamSandboxLogsAsync( + this Proto.ServerlessActivities.ServerlessActivitiesClient client, + string sandboxId, + int tail = 100, + CancellationToken cancellation = default) + { + return StreamSandboxLogsCoreAsync( + client, + sandboxId, + taskHub: null, + tail, + cancellation); + } + + /// + /// Streams logs from a serverless activity sandbox with explicit task hub metadata. + /// + /// The generated serverless activities gRPC client. + /// The sandbox ID to stream logs from. + /// The task hub that owns the sandbox. + /// The number of historical log lines to include before streaming live logs. Must be between 0 and 300. + /// The cancellation token used to stop streaming. + /// An async stream of sandbox log lines. + public static IAsyncEnumerable StreamSandboxLogsAsync( + this Proto.ServerlessActivities.ServerlessActivitiesClient client, + string sandboxId, + string taskHub, + int tail = 100, + CancellationToken cancellation = default) + { + if (string.IsNullOrWhiteSpace(taskHub)) + { + throw new ArgumentException("Task hub name is required.", nameof(taskHub)); + } + + return StreamSandboxLogsCoreAsync( + client, + sandboxId, + taskHub, + tail, + cancellation); + } + + static async IAsyncEnumerable StreamSandboxLogsCoreAsync( + Proto.ServerlessActivities.ServerlessActivitiesClient client, + string sandboxId, + string? taskHub, + int tail, + [EnumeratorCancellation] CancellationToken cancellation) + { + ArgumentNullException.ThrowIfNull(client); + ValidateRequest(sandboxId, tail); + + Proto.SandboxLogStreamRequest request = new() + { + SandboxId = sandboxId, + Tail = tail, + }; + + Metadata? headers = taskHub is null ? null : new Metadata { { "taskhub", taskHub } }; + using AsyncServerStreamingCall call = client.StreamSandboxLogs( + request, + headers: headers, + cancellationToken: cancellation); + + while (await call.ResponseStream.MoveNext(cancellation).ConfigureAwait(false)) + { + yield return FromProto(call.ResponseStream.Current); + } + } + + static void ValidateRequest(string sandboxId, int tail) + { + if (string.IsNullOrWhiteSpace(sandboxId)) + { + throw new ArgumentException("Sandbox ID is required.", nameof(sandboxId)); + } + + if (tail < MinTail || tail > MaxTail) + { + throw new ArgumentOutOfRangeException( + nameof(tail), + tail, + $"Tail must be between {MinTail} and {MaxTail}."); + } + } + + static ServerlessSandboxLogLine FromProto(Proto.SandboxLogLine line) => new( + line.SandboxId, + line.Timestamp?.ToDateTimeOffset() ?? default, + line.Stream, + line.Tag, + line.Message, + line.RawLine); +} diff --git a/src/Extensions/AzureManagedServerless/Client/ServerlessSandboxLogLine.cs b/src/Extensions/AzureManagedServerless/Client/ServerlessSandboxLogLine.cs new file mode 100644 index 00000000..06389a45 --- /dev/null +++ b/src/Extensions/AzureManagedServerless/Client/ServerlessSandboxLogLine.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Client.AzureManaged; + +/// +/// A log line emitted by a serverless activity sandbox. +/// +/// The sandbox ID that produced the log line. +/// The timestamp associated with the log line. +/// The output stream that produced the line, such as stdout or stderr. +/// The log tag reported by the sandbox runtime. +/// The parsed log message. +/// The original log line. +public sealed record ServerlessSandboxLogLine( + string SandboxId, + DateTimeOffset Timestamp, + string Stream, + string Tag, + string Message, + string RawLine); diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs new file mode 100644 index 00000000..b3050fbc --- /dev/null +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; +using Grpc.Net.Client; +using Microsoft.DurableTask.Protobuf.Serverless; +using Microsoft.DurableTask.Worker.AzureManaged.Serverless; +using Microsoft.DurableTask.Worker.Grpc; +using Microsoft.DurableTask.Worker.Grpc.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.DurableTask.Worker.AzureManaged; + +/// +/// Extension methods for configuring Azure Managed Durable Task workers with serverless activity support. +/// +public static class DurableTaskSchedulerServerlessWorkerExtensions +{ + /// + /// Configures serverless activity declaration, local exclusion, and serverless worker registration. + /// + /// The Durable Task worker builder to configure. + /// Optional callback to configure serverless activity behavior. + /// The original builder, for call chaining. + public static IDurableTaskWorkerBuilder UseServerlessActivities( + this IDurableTaskWorkerBuilder builder, + Action? configure = null) + { + Check.NotNull(builder); + + builder.Services.AddOptions(builder.Name) + .Configure(configure ?? (_ => { })) + .PostConfigure>((options, schedulerOptions) => + { + ApplyTaskHubDefault(options, schedulerOptions.Get(builder.Name).TaskHubName); + ApplyServerlessEnvironmentOverrides(options); + }); + + builder.Services.AddOptions(builder.Name) + .PostConfigure>( + (filters, serverlessOptions) => + { + ServerlessOptions options = serverlessOptions.Get(builder.Name); + string[] activityNames = ServerlessActivityConfiguration.ResolveActivityNames(options.ActivityNames); + if (activityNames.Length == 0) + { + return; + } + + if (options.Mode == ServerlessMode.ServerlessInclude) + { + filters.Orchestrations = []; + filters.Activities = activityNames + .Select(static name => new DurableTaskWorkerWorkItemFilters.ActivityFilter { Name = name }) + .ToArray(); + filters.ExcludedActivities = []; + filters.Entities = []; + return; + } + + filters.ExcludedActivities = MergeActivityFilters(filters.ExcludedActivities, activityNames); + }); + + builder.Services.AddSingleton(sp => CreateServerlessActivityDeclarationHostedService(sp, builder.Name)); + builder.Services.AddSingleton(sp => CreateServerlessActivityWorkerRegistrationHostedService(sp, builder.Name)); + return builder; + } + + static ServerlessActivityDeclarationHostedService CreateServerlessActivityDeclarationHostedService( + IServiceProvider services, + string builderName) + { + ServerlessOptions options = services.GetRequiredService>().Get(builderName); + ILoggerFactory loggerFactory = services.GetRequiredService(); + + return new ServerlessActivityDeclarationHostedService( + CreateServerlessActivitiesClient(services, builderName), + options, + loggerFactory.CreateLogger()); + } + + static ServerlessActivityWorkerRegistrationHostedService CreateServerlessActivityWorkerRegistrationHostedService( + IServiceProvider services, + string builderName) + { + ServerlessOptions options = services.GetRequiredService>().Get(builderName); + ILoggerFactory loggerFactory = services.GetRequiredService(); + IHostApplicationLifetime? lifetime = services.GetService(); + + return new ServerlessActivityWorkerRegistrationHostedService( + CreateServerlessActivitiesClient(services, builderName), + options, + loggerFactory.CreateLogger(), + lifetime); + } + + static ServerlessActivitiesClientAdapter CreateServerlessActivitiesClient(IServiceProvider services, string builderName) + { + GrpcDurableTaskWorkerOptions options = services.GetRequiredService>().Get(builderName); + if (options.CallInvoker is { } callInvoker) + { + return new ServerlessActivitiesClientAdapter(new ServerlessActivities.ServerlessActivitiesClient(callInvoker)); + } + + if (options.Channel is { } channel) + { + return new ServerlessActivitiesClientAdapter(new ServerlessActivities.ServerlessActivitiesClient(channel.CreateCallInvoker())); + } + + throw new InvalidOperationException("Azure Managed serverless activities require a configured gRPC channel or call invoker."); + } + + static void ApplyTaskHubDefault(ServerlessOptions options, string taskHubName) + { + if (string.IsNullOrWhiteSpace(options.TaskHub) && !string.IsNullOrWhiteSpace(taskHubName)) + { + options.TaskHub = taskHubName; + } + } + + static void ApplyServerlessEnvironmentOverrides(ServerlessOptions options) + { + string? mode = Environment.GetEnvironmentVariable("DTS_SERVERLESS_MODE"); + if (!string.IsNullOrWhiteSpace(mode)) + { + options.Mode = string.Equals(mode, "serverless-worker", StringComparison.OrdinalIgnoreCase) + || string.Equals(mode, nameof(ServerlessMode.ServerlessInclude), StringComparison.OrdinalIgnoreCase) + ? ServerlessMode.ServerlessInclude + : ServerlessMode.LocalExclude; + } + + ApplyActivityNameEnvironmentOverride(options.ActivityNames); + ApplyWorkerProfileEnvironmentOverride(profile => options.WorkerProfileId = profile); + + string? image = Environment.GetEnvironmentVariable("DTS_SERVERLESS_ACTIVITY_IMAGE"); + if (!string.IsNullOrWhiteSpace(image)) + { + options.ContainerImage = image; + } + + string? cpu = Environment.GetEnvironmentVariable("DTS_SERVERLESS_CPU"); + if (!string.IsNullOrWhiteSpace(cpu)) + { + options.Cpu = cpu.Trim(); + } + + string? memory = Environment.GetEnvironmentVariable("DTS_SERVERLESS_MEMORY"); + if (!string.IsNullOrWhiteSpace(memory)) + { + options.Memory = memory.Trim(); + } + + string? launchCommand = Environment.GetEnvironmentVariable("DTS_SERVERLESS_LAUNCH_COMMAND"); + if (!string.IsNullOrWhiteSpace(launchCommand)) + { + options.LaunchCommand = launchCommand; + } + + if (int.TryParse(Environment.GetEnvironmentVariable("DTS_SERVERLESS_MAX_ACTIVITIES"), out int maxActivities) && maxActivities > 0) + { + options.MaxConcurrentActivities = maxActivities; + } + } + + static void ApplyActivityNameEnvironmentOverride(ICollection activityNames) + { + string? serverlessActivities = Environment.GetEnvironmentVariable("DTS_SERVERLESS_ACTIVITIES"); + if (serverlessActivities is null) + { + return; + } + + activityNames.Clear(); + foreach (string name in serverlessActivities + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Distinct(StringComparer.Ordinal)) + { + activityNames.Add(name); + } + } + + static void ApplyWorkerProfileEnvironmentOverride(Action setWorkerProfileId) + { + string? workerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID"); + if (!string.IsNullOrWhiteSpace(workerProfileId)) + { + setWorkerProfileId(workerProfileId.Trim()); + } + } + + static DurableTaskWorkerWorkItemFilters.ActivityFilter[] MergeActivityFilters( + IReadOnlyList existingFilters, + IEnumerable activityNames) + { + Dictionary merged = new(StringComparer.OrdinalIgnoreCase); + foreach (DurableTaskWorkerWorkItemFilters.ActivityFilter filter in existingFilters) + { + if (!string.IsNullOrWhiteSpace(filter.Name)) + { + merged[filter.Name] = filter; + } + } + + foreach (string activityName in activityNames) + { + merged[activityName] = new DurableTaskWorkerWorkItemFilters.ActivityFilter { Name = activityName }; + } + + return merged.Values.ToArray(); + } +} diff --git a/src/Worker/AzureManaged/Serverless/ServerlessActivitiesClientAdapter.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs similarity index 52% rename from src/Worker/AzureManaged/Serverless/ServerlessActivitiesClientAdapter.cs rename to src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs index 27ed6bb7..4ac4735e 100644 --- a/src/Worker/AzureManaged/Serverless/ServerlessActivitiesClientAdapter.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs @@ -12,34 +12,37 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; interface IServerlessActivitiesClient { /// - /// Declares remote activities to DTS. + /// Declares serverless activities to DTS. /// /// The declaration message. + /// The task hub that owns the declaration. /// The cancellation token. /// The declaration result. - Task DeclareRemoteActivitiesAsync( - Proto.RemoteActivityDeclaration declaration, + Task DeclareServerlessActivitiesAsync( + Proto.ServerlessActivityDeclaration declaration, + string taskHub, CancellationToken cancellationToken); /// - /// Opens a remote activity worker registration session. + /// Opens a serverless activity worker registration session. /// + /// The task hub that owns the worker session. /// The cancellation token. /// The worker registration session. - IRemoteActivityWorkerSession OpenRemoteActivityWorkerSession(CancellationToken cancellationToken); + IServerlessActivityWorkerSession OpenServerlessActivityWorkerSession(string taskHub, CancellationToken cancellationToken); } /// -/// Client-streaming session used by a remote activity worker registration. +/// Client-streaming session used by a serverless activity worker registration. /// -interface IRemoteActivityWorkerSession : IAsyncDisposable +interface IServerlessActivityWorkerSession : IAsyncDisposable { /// /// Writes a worker registration message to the stream. /// /// The message to write. /// A task that completes when the message is written. - Task WriteMessageAsync(Proto.RemoteActivityWorkerMessage message); + Task WriteMessageAsync(Proto.ServerlessActivityWorkerMessage message); /// /// Completes the request stream. @@ -65,40 +68,46 @@ public ServerlessActivitiesClientAdapter(Proto.ServerlessActivities.ServerlessAc } /// - public async Task DeclareRemoteActivitiesAsync( - Proto.RemoteActivityDeclaration declaration, + public async Task DeclareServerlessActivitiesAsync( + Proto.ServerlessActivityDeclaration declaration, + string taskHub, CancellationToken cancellationToken) { - return await this.client.DeclareRemoteActivitiesAsync(declaration, cancellationToken: cancellationToken) + return await this.client.DeclareServerlessActivitiesAsync( + declaration, + headers: CreateTaskHubHeaders(taskHub), + cancellationToken: cancellationToken) .ResponseAsync.ConfigureAwait(false); } /// - public IRemoteActivityWorkerSession OpenRemoteActivityWorkerSession(CancellationToken cancellationToken) + public IServerlessActivityWorkerSession OpenServerlessActivityWorkerSession(string taskHub, CancellationToken cancellationToken) { - AsyncClientStreamingCall call = - this.client.ConnectRemoteActivityWorker(cancellationToken: cancellationToken); - return new GrpcRemoteActivityWorkerSession(call); + AsyncClientStreamingCall call = + this.client.ConnectServerlessActivityWorker(headers: CreateTaskHubHeaders(taskHub), cancellationToken: cancellationToken); + return new GrpcServerlessActivityWorkerSession(call); } + static Metadata CreateTaskHubHeaders(string taskHub) => new() { { "taskhub", taskHub } }; + /// - /// gRPC-backed remote activity worker registration session. + /// gRPC-backed serverless activity worker registration session. /// - sealed class GrpcRemoteActivityWorkerSession : IRemoteActivityWorkerSession + sealed class GrpcServerlessActivityWorkerSession : IServerlessActivityWorkerSession { - readonly AsyncClientStreamingCall call; + readonly AsyncClientStreamingCall call; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The active gRPC client-streaming call. - public GrpcRemoteActivityWorkerSession(AsyncClientStreamingCall call) + public GrpcServerlessActivityWorkerSession(AsyncClientStreamingCall call) { this.call = call; } /// - public Task WriteMessageAsync(Proto.RemoteActivityWorkerMessage message) => + public Task WriteMessageAsync(Proto.ServerlessActivityWorkerMessage message) => this.call.RequestStream.WriteAsync(message); /// diff --git a/src/Worker/AzureManaged/Serverless/RemoteActivityConfiguration.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs similarity index 56% rename from src/Worker/AzureManaged/Serverless/RemoteActivityConfiguration.cs rename to src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs index 3c18bf3f..66239428 100644 --- a/src/Worker/AzureManaged/Serverless/RemoteActivityConfiguration.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs @@ -6,12 +6,12 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; /// -/// Builds and normalizes remote activity protocol messages. +/// Builds and normalizes serverless activity protocol messages. /// -static class RemoteActivityConfiguration +static class ServerlessActivityConfiguration { /// - /// Resolves configured activity names for a remote activity worker. + /// Resolves configured activity names for serverless activity execution. /// /// The configured activity names. /// The normalized activity names. @@ -25,68 +25,65 @@ public static string[] ResolveActivityNames(ICollection configuredNames) } /// - /// Builds a remote activity declaration protocol message. + /// Builds a serverless activity declaration protocol message. /// - /// The declaration options. + /// The serverless options. /// The activity names included in the declaration. /// The declaration protocol message. - public static Proto.RemoteActivityDeclaration BuildDeclaration(RemoteActivityOptions options, IReadOnlyCollection activityNames) + public static Proto.ServerlessActivityDeclaration BuildDeclaration(ServerlessOptions options, IReadOnlyCollection activityNames) { Check.NotNull(options); Check.NotNull(activityNames); - if (string.IsNullOrWhiteSpace(options.TaskHub)) - { - throw new InvalidOperationException("Remote activity declaration requires a task hub name."); - } + ValidateTaskHub(options.TaskHub, "Serverless activity declaration requires a task hub name."); if (activityNames.Count == 0) { - throw new InvalidOperationException("Remote activity declaration requires at least one activity name."); + throw new InvalidOperationException("Serverless activity declaration requires at least one activity name."); } - string workerProfileId = NormalizeWorkerProfileId(options.WorkerProfileId, "Remote activity declaration requires a worker profile ID."); + string workerProfileId = NormalizeWorkerProfileId(options.WorkerProfileId, "Serverless activity declaration requires a worker profile ID."); if (options.MaxConcurrentActivities <= 0) { - throw new InvalidOperationException("Remote activity max concurrent activities must be greater than zero."); + throw new InvalidOperationException("Serverless activity max concurrent activities must be greater than zero."); } - Proto.RemoteActivityDeclaration declaration = new() + Proto.ServerlessActivityDeclaration declaration = new() { - TaskHub = options.TaskHub, WorkerProfileId = workerProfileId, Image = BuildImage(options), + Resources = BuildResources(options), + LaunchCommand = options.LaunchCommand ?? string.Empty, MaxConcurrentActivities = options.MaxConcurrentActivities, }; declaration.ActivityNames.AddRange(activityNames); declaration.EnvironmentVariables.Add(options.EnvironmentVariables); + declaration.Entrypoint.AddRange(NormalizeOptionalStrings(options.Entrypoint)); + declaration.Cmd.AddRange(NormalizeOptionalStrings(options.Cmd)); return declaration; } /// - /// Builds the initial remote activity worker registration message. + /// Builds the initial serverless activity worker registration message. /// - /// The worker options. + /// The serverless options. /// The worker start protocol message. - public static Proto.RemoteActivityWorkerMessage BuildWorkerStart(RemoteActivityWorkerOptions options) + public static Proto.ServerlessActivityWorkerMessage BuildWorkerStart(ServerlessOptions options) { Check.NotNull(options); - if (string.IsNullOrWhiteSpace(options.TaskHub)) - { - throw new InvalidOperationException("Remote activity worker registration requires a task hub name."); - } + ValidateTaskHub(options.TaskHub, "Serverless activity worker registration requires a task hub name."); if (options.MaxConcurrentActivities <= 0) { - throw new InvalidOperationException("Remote activity worker max concurrent activities must be greater than zero."); + throw new InvalidOperationException("Serverless activity worker max concurrent activities must be greater than zero."); } - string workerProfileId = NormalizeWorkerProfileId(options.WorkerProfileId, "Remote activity worker registration requires a worker profile ID."); + string workerProfileId = NormalizeWorkerProfileId(options.WorkerProfileId, "Serverless activity worker registration requires a worker profile ID."); - Proto.RemoteActivityWorkerStart start = new() + Proto.ServerlessActivityWorkerStart start = new() { TaskHub = options.TaskHub, WorkerProfileId = workerProfileId, @@ -96,35 +93,35 @@ public static Proto.RemoteActivityWorkerMessage BuildWorkerStart(RemoteActivityW SandboxId = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID") ?? string.Empty, }; - return new Proto.RemoteActivityWorkerMessage { Start = start }; + return new Proto.ServerlessActivityWorkerMessage { Start = start }; } /// - /// Builds a remote activity worker heartbeat message. + /// Builds a serverless activity worker heartbeat message. /// /// The number of activities currently executing. /// The heartbeat protocol message. - public static Proto.RemoteActivityWorkerMessage BuildWorkerHeartbeat(int activeActivitiesCount) + public static Proto.ServerlessActivityWorkerMessage BuildWorkerHeartbeat(int activeActivitiesCount) { if (activeActivitiesCount < 0) { - throw new InvalidOperationException("Remote activity worker active activity count cannot be negative."); + throw new InvalidOperationException("Serverless activity worker active activity count cannot be negative."); } - return new Proto.RemoteActivityWorkerMessage + return new Proto.ServerlessActivityWorkerMessage { - Heartbeat = new Proto.RemoteActivityWorkerHeartbeat + Heartbeat = new Proto.ServerlessActivityWorkerHeartbeat { ActiveActivitiesCount = activeActivitiesCount, }, }; } - static Proto.RemoteActivityImage BuildImage(RemoteActivityOptions options) + static Proto.ServerlessActivityImage BuildImage(ServerlessOptions options) { if (!options.PublicPull) { - throw new InvalidOperationException("Remote activity images must be publicly pullable for private preview."); + throw new InvalidOperationException("Serverless activity images must be publicly pullable for private preview."); } string? imageRef = Coalesce( @@ -133,16 +130,28 @@ static Proto.RemoteActivityImage BuildImage(RemoteActivityOptions options) if (string.IsNullOrWhiteSpace(imageRef)) { - throw new InvalidOperationException("Remote activity image metadata requires a container image reference."); + throw new InvalidOperationException("Serverless activity image metadata requires a container image reference."); } - return new Proto.RemoteActivityImage + return new Proto.ServerlessActivityImage { ImageRef = imageRef, PublicPull = true, }; } + static Proto.ServerlessActivityResources BuildResources(ServerlessOptions options) + { + string cpu = NormalizeRequired(options.Cpu, "Serverless activity declaration requires CPU resources."); + string memory = NormalizeRequired(options.Memory, "Serverless activity declaration requires memory resources."); + + return new Proto.ServerlessActivityResources + { + Cpu = cpu, + Memory = memory, + }; + } + static Proto.SubstrateKind GetSubstrateFromEnvironment() { string? substrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); @@ -164,7 +173,17 @@ static Proto.SubstrateKind GetSubstrateFromEnvironment() return Proto.SubstrateKind.Unspecified; } + static void ValidateTaskHub(string value, string errorMessage) + { + _ = NormalizeRequired(value, errorMessage); + } + static string NormalizeWorkerProfileId(string value, string errorMessage) + { + return NormalizeRequired(value, errorMessage); + } + + static string NormalizeRequired(string value, string errorMessage) { if (string.IsNullOrWhiteSpace(value)) { @@ -174,6 +193,14 @@ static string NormalizeWorkerProfileId(string value, string errorMessage) return value.Trim(); } + static string[] NormalizeOptionalStrings(IEnumerable values) + { + return values + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value.Trim()) + .ToArray(); + } + static string? BuildImageRef(string? registryServer, string? repository, string? tag, string? digest) { if (string.IsNullOrWhiteSpace(repository)) diff --git a/src/Worker/AzureManaged/Serverless/RemoteActivityDeclarationHostedService.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs similarity index 60% rename from src/Worker/AzureManaged/Serverless/RemoteActivityDeclarationHostedService.cs rename to src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs index 702d8993..eecf5fda 100644 --- a/src/Worker/AzureManaged/Serverless/RemoteActivityDeclarationHostedService.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs @@ -9,24 +9,24 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; /// -/// Hosted service that declares remote activities with DTS when the local worker starts. +/// Hosted service that declares serverless activities with DTS when the local worker starts. /// -sealed partial class RemoteActivityDeclarationHostedService : IHostedService +sealed partial class ServerlessActivityDeclarationHostedService : IHostedService { readonly IServerlessActivitiesClient client; - readonly RemoteActivityOptions options; - readonly ILogger logger; + readonly ServerlessOptions options; + readonly ILogger logger; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The serverless activities client. - /// The remote activity options. + /// The serverless options. /// The logger. - public RemoteActivityDeclarationHostedService( + public ServerlessActivityDeclarationHostedService( IServerlessActivitiesClient client, - RemoteActivityOptions options, - ILogger logger) + ServerlessOptions options, + ILogger logger) { this.client = Check.NotNull(client); this.options = Check.NotNull(options); @@ -36,33 +36,40 @@ public RemoteActivityDeclarationHostedService( /// /// Gets a task completed when the declaration attempt succeeds, is skipped, or fails. /// - internal TaskCompletionSource Ready { get; } = + internal TaskCompletionSource Ready { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); /// public async Task StartAsync(CancellationToken cancellationToken) { - string[] activityNames = RemoteActivityConfiguration.ResolveActivityNames(this.options.ActivityNames); + if (this.options.Mode == ServerlessMode.ServerlessInclude) + { + this.Ready.TrySetResult(null); + return; + } + + string[] activityNames = ServerlessActivityConfiguration.ResolveActivityNames(this.options.ActivityNames); if (activityNames.Length == 0) { - Log.NoRemoteActivitiesDiscovered(this.logger, this.options.TaskHub); + Log.NoServerlessActivitiesDiscovered(this.logger, this.options.TaskHub); this.Ready.TrySetResult(null); return; } - Proto.RemoteActivityDeclaration declaration = RemoteActivityConfiguration.BuildDeclaration(this.options, activityNames); + Proto.ServerlessActivityDeclaration declaration = ServerlessActivityConfiguration.BuildDeclaration(this.options, activityNames); int maxAttempts = Math.Max(1, this.options.DeclarationRetryMaxAttempts); for (int attempt = 1; ; attempt++) { try { - Proto.RemoteActivityDeclarationResult result = await this.client.DeclareRemoteActivitiesAsync( + Proto.ServerlessActivityDeclarationResult result = await this.client.DeclareServerlessActivitiesAsync( declaration, + this.options.TaskHub, cancellationToken).ConfigureAwait(false); this.Ready.TrySetResult(result); - Log.RemoteActivitiesDeclared( + Log.ServerlessActivitiesDeclared( this.logger, - declaration.TaskHub, + this.options.TaskHub, declaration.WorkerProfileId, declaration.ActivityNames.Count, declaration.Image?.ImageRef ?? string.Empty); @@ -70,7 +77,7 @@ public async Task StartAsync(CancellationToken cancellationToken) } catch (Exception ex) when (IsTransient(ex) && attempt < maxAttempts && !cancellationToken.IsCancellationRequested) { - Log.RemoteActivityDeclarationRetry(this.logger, ex, declaration.TaskHub, attempt, maxAttempts); + Log.ServerlessActivityDeclarationRetry(this.logger, ex, this.options.TaskHub, attempt, maxAttempts); if (this.options.DeclarationRetryDelay > TimeSpan.Zero) { await Task.Delay(this.options.DeclarationRetryDelay, cancellationToken).ConfigureAwait(false); @@ -79,7 +86,7 @@ public async Task StartAsync(CancellationToken cancellationToken) catch (Exception ex) { this.Ready.TrySetException(ex); - Log.RemoteActivityDeclarationFailed(this.logger, ex, declaration.TaskHub); + Log.ServerlessActivityDeclarationFailed(this.logger, ex, this.options.TaskHub); throw; } } @@ -98,29 +105,29 @@ exception is RpcException rpcException static partial class Log { /// - /// Logs that no remote activities were discovered for declaration. + /// Logs that no serverless activities were discovered for declaration. /// /// The logger. /// The task hub name. [LoggerMessage( EventId = 1, Level = LogLevel.Information, - Message = "No remote activities discovered for hub={Hub}; skipping declaration")] - public static partial void NoRemoteActivitiesDiscovered(ILogger logger, string hub); + Message = "No serverless activities discovered for hub={Hub}; skipping declaration")] + public static partial void NoServerlessActivitiesDiscovered(ILogger logger, string hub); /// - /// Logs a successful remote activity declaration. + /// Logs a successful serverless activity declaration. /// /// The logger. /// The task hub name. /// The worker profile ID. /// The declared activity count. - /// The remote worker image reference. + /// The serverless worker image reference. [LoggerMessage( EventId = 2, Level = LogLevel.Information, - Message = "Remote activities declared hub={Hub} workerProfile={WorkerProfile} count={Count} image={Image}")] - public static partial void RemoteActivitiesDeclared( + Message = "Serverless activities declared hub={Hub} workerProfile={WorkerProfile} count={Count} image={Image}")] + public static partial void ServerlessActivitiesDeclared( ILogger logger, string hub, string workerProfile, @@ -128,18 +135,18 @@ public static partial void RemoteActivitiesDeclared( string image); /// - /// Logs a transient remote activity declaration failure that will be retried. + /// Logs a transient serverless activity declaration failure that will be retried. /// /// The logger. /// The transient exception. /// The task hub name. - /// The current attempt number. + /// The completed attempt count. /// The maximum attempt count. [LoggerMessage( EventId = 3, Level = LogLevel.Warning, - Message = "Remote activity declaration failed transiently hub={Hub} attempt={Attempt} maxAttempts={MaxAttempts}")] - public static partial void RemoteActivityDeclarationRetry( + Message = "Serverless activity declaration failed transiently hub={Hub} attempt={Attempt} maxAttempts={MaxAttempts}")] + public static partial void ServerlessActivityDeclarationRetry( ILogger logger, Exception exception, string hub, @@ -147,7 +154,7 @@ public static partial void RemoteActivityDeclarationRetry( int maxAttempts); /// - /// Logs a failed remote activity declaration. + /// Logs a failed serverless activity declaration. /// /// The logger. /// The declaration exception. @@ -155,7 +162,7 @@ public static partial void RemoteActivityDeclarationRetry( [LoggerMessage( EventId = 4, Level = LogLevel.Error, - Message = "Remote activity declaration failed hub={Hub}")] - public static partial void RemoteActivityDeclarationFailed(ILogger logger, Exception exception, string hub); + Message = "Serverless activity declaration failed hub={Hub}")] + public static partial void ServerlessActivityDeclarationFailed(ILogger logger, Exception exception, string hub); } } diff --git a/src/Worker/AzureManaged/Serverless/RemoteActivityWorkerRegistrationHostedService.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs similarity index 67% rename from src/Worker/AzureManaged/Serverless/RemoteActivityWorkerRegistrationHostedService.cs rename to src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs index 47ccfafc..eb9b8a95 100644 --- a/src/Worker/AzureManaged/Serverless/RemoteActivityWorkerRegistrationHostedService.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs @@ -9,29 +9,29 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; /// -/// Hosted service that registers a running process as a remote activity worker with DTS. +/// Hosted service that registers a running process as a serverless activity worker with DTS. /// -sealed partial class RemoteActivityWorkerRegistrationHostedService : IHostedService, IAsyncDisposable +sealed partial class ServerlessActivityWorkerRegistrationHostedService : IHostedService, IAsyncDisposable { readonly IServerlessActivitiesClient client; - readonly RemoteActivityWorkerOptions options; - readonly ILogger logger; + readonly ServerlessOptions options; + readonly ILogger logger; readonly IHostApplicationLifetime? lifetime; CancellationTokenSource? cts; - IRemoteActivityWorkerSession? session; + IServerlessActivityWorkerSession? session; Task? pump; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The serverless activities client. - /// The remote activity worker options. + /// The serverless options. /// The logger. /// The optional application lifetime used to stop the host when the registration stream fails. - public RemoteActivityWorkerRegistrationHostedService( + public ServerlessActivityWorkerRegistrationHostedService( IServerlessActivitiesClient client, - RemoteActivityWorkerOptions options, - ILogger logger, + ServerlessOptions options, + ILogger logger, IHostApplicationLifetime? lifetime = null) { this.client = Check.NotNull(client); @@ -48,10 +48,17 @@ public RemoteActivityWorkerRegistrationHostedService( /// public async Task StartAsync(CancellationToken cancellationToken) { - string[] activityNames = RemoteActivityConfiguration.ResolveActivityNames(this.options.ActivityNames); + if (this.options.Mode != ServerlessMode.ServerlessInclude) + { + this.Ready.TrySetResult(true); + this.pump = Task.CompletedTask; + return; + } + + string[] activityNames = ServerlessActivityConfiguration.ResolveActivityNames(this.options.ActivityNames); if (activityNames.Length == 0) { - Log.NoRemoteActivitiesDiscovered(this.logger, this.options.TaskHub); + Log.NoServerlessActivitiesDiscovered(this.logger, this.options.TaskHub); this.Ready.TrySetResult(true); this.pump = Task.CompletedTask; return; @@ -59,15 +66,15 @@ public async Task StartAsync(CancellationToken cancellationToken) CancellationTokenSource registrationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); this.cts = registrationCts; - IRemoteActivityWorkerSession registrationSession = this.client.OpenRemoteActivityWorkerSession(registrationCts.Token); + IServerlessActivityWorkerSession registrationSession = this.client.OpenServerlessActivityWorkerSession(this.options.TaskHub, registrationCts.Token); this.session = registrationSession; - Proto.RemoteActivityWorkerMessage startMessage = RemoteActivityConfiguration.BuildWorkerStart(this.options); + Proto.ServerlessActivityWorkerMessage startMessage = ServerlessActivityConfiguration.BuildWorkerStart(this.options); try { await registrationSession.WriteMessageAsync(startMessage).ConfigureAwait(false); this.Ready.TrySetResult(true); - Log.RemoteActivityWorkerRegistered( + Log.ServerlessActivityWorkerRegistered( this.logger, startMessage.Start.TaskHub, startMessage.Start.WorkerInstanceId, @@ -78,7 +85,7 @@ public async Task StartAsync(CancellationToken cancellationToken) catch (Exception ex) { this.Ready.TrySetException(ex); - Log.RemoteActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); + Log.ServerlessActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); throw; } @@ -91,7 +98,7 @@ public async Task StartAsync(CancellationToken cancellationToken) public async Task StopAsync(CancellationToken cancellationToken) { CancellationTokenSource? localCts = this.cts; - IRemoteActivityWorkerSession? localSession = this.session; + IServerlessActivityWorkerSession? localSession = this.session; localCts?.Cancel(); if (localSession is not null) @@ -142,7 +149,7 @@ public async Task StopAsync(CancellationToken cancellationToken) public ValueTask DisposeAsync() => new(this.StopAsync(CancellationToken.None)); async Task PumpHeartbeatsAsync( - IRemoteActivityWorkerSession registrationSession, + IServerlessActivityWorkerSession registrationSession, CancellationToken cancellationToken) { try @@ -151,7 +158,7 @@ async Task PumpHeartbeatsAsync( while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) { await registrationSession.WriteMessageAsync( - RemoteActivityConfiguration.BuildWorkerHeartbeat(activeActivitiesCount: 0)).ConfigureAwait(false); + ServerlessActivityConfiguration.BuildWorkerHeartbeat(activeActivitiesCount: 0)).ConfigureAwait(false); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -165,25 +172,25 @@ await registrationSession.WriteMessageAsync( void HandleRegistrationStreamFailure(Exception exception) { - Log.RemoteActivityWorkerRegistrationFailed(this.logger, exception, this.options.TaskHub); + Log.ServerlessActivityWorkerRegistrationFailed(this.logger, exception, this.options.TaskHub); this.lifetime?.StopApplication(); } static partial class Log { /// - /// Logs that no remote activities were discovered for live worker registration. + /// Logs that no serverless activities were discovered for live worker registration. /// /// The logger. /// The task hub name. [LoggerMessage( EventId = 1, Level = LogLevel.Information, - Message = "No remote activities discovered for worker hub={Hub}; skipping live registration")] - public static partial void NoRemoteActivitiesDiscovered(ILogger logger, string hub); + Message = "No serverless activities discovered for worker hub={Hub}; skipping live registration")] + public static partial void NoServerlessActivitiesDiscovered(ILogger logger, string hub); /// - /// Logs a successful remote activity worker registration. + /// Logs a successful serverless activity worker registration. /// /// The logger. /// The task hub name. @@ -194,8 +201,8 @@ static partial class Log [LoggerMessage( EventId = 2, Level = LogLevel.Information, - Message = "Remote activity worker registered hub={Hub} worker={Worker} count={Count} substrate={Substrate} sandboxId={SandboxId}")] - public static partial void RemoteActivityWorkerRegistered( + Message = "Serverless activity worker registered hub={Hub} worker={Worker} count={Count} substrate={Substrate} sandboxId={SandboxId}")] + public static partial void ServerlessActivityWorkerRegistered( ILogger logger, string hub, string worker, @@ -204,7 +211,7 @@ public static partial void RemoteActivityWorkerRegistered( string sandboxId); /// - /// Logs a failed remote activity worker registration stream. + /// Logs a failed serverless activity worker registration stream. /// /// The logger. /// The registration exception. @@ -212,7 +219,7 @@ public static partial void RemoteActivityWorkerRegistered( [LoggerMessage( EventId = 3, Level = LogLevel.Error, - Message = "Remote activity worker registration stream failed hub={Hub}")] - public static partial void RemoteActivityWorkerRegistrationFailed(ILogger logger, Exception exception, string hub); + Message = "Serverless activity worker registration stream failed hub={Hub}")] + public static partial void ServerlessActivityWorkerRegistrationFailed(ILogger logger, Exception exception, string hub); } } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs new file mode 100644 index 00000000..4551797a --- /dev/null +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; + +/// +/// Defines how a worker participates in serverless activity execution. +/// +public enum ServerlessMode +{ + /// + /// The local worker declares serverless activities and excludes them from local execution. + /// + LocalExclude, + + /// + /// The worker runs inside serverless infrastructure and executes only serverless activities. + /// + ServerlessInclude, +} + +/// +/// Options for configuring serverless activity worker behavior. +/// +public sealed class ServerlessOptions +{ + /// + /// Default worker profile ID used when no profile is specified. + /// + internal const string DefaultWorkerProfileId = "default"; + + /// + /// Gets or sets the worker mode for serverless activity execution. + /// + public ServerlessMode Mode { get; set; } = ServerlessMode.LocalExclude; + + /// + /// Gets the serverless activity names to declare or execute. + /// + public IList ActivityNames { get; } = new List(); + + /// + /// Gets or sets the task hub used by serverless activity calls. + /// + public string TaskHub { get; set; } = string.Empty; + + /// + /// Gets or sets the worker profile ID used for the serverless activity pool. + /// + public string WorkerProfileId { get; set; } = DefaultWorkerProfileId; + + /// + /// Gets or sets the full container image reference for serverless workers. + /// + public string? ContainerImage { get; set; } + + /// + /// Gets or sets the registry server for the serverless worker image. + /// + public string? RegistryServer { get; set; } + + /// + /// Gets or sets the repository for the serverless worker image. + /// + public string? Repository { get; set; } + + /// + /// Gets or sets the tag for the serverless worker image. + /// + public string? Tag { get; set; } + + /// + /// Gets or sets the digest for the serverless worker image. + /// + public string? ImageDigest { get; set; } + + /// + /// Gets or sets a value indicating whether the image is publicly pullable. Private preview requires this to be true. + /// + public bool PublicPull { get; set; } = true; + + /// + /// Gets or sets the CPU quantity declared for each serverless sandbox. + /// + public string Cpu { get; set; } = "1000m"; + + /// + /// Gets or sets the memory quantity declared for each serverless sandbox. + /// + public string Memory { get; set; } = "2048Mi"; + + /// + /// Gets environment variables DTS should provide to serverless workers created from this declaration. + /// + public IDictionary EnvironmentVariables { get; } = new Dictionary(StringComparer.Ordinal); + + /// + /// Gets the sandbox entrypoint declared for serverless workers. + /// + public IList Entrypoint { get; } = new List(); + + /// + /// Gets the sandbox command declared for serverless workers. + /// + public IList Cmd { get; } = new List(); + + /// + /// Gets or sets the shell command exec'd after the sandbox reaches Running. + /// + public string LaunchCommand { get; set; } = string.Empty; + + /// + /// Gets the unique worker instance identifier. + /// + public string WorkerInstanceId { get; } = Guid.NewGuid().ToString("N"); + + /// + /// Gets or sets the maximum number of concurrent activities expected from each serverless worker. + /// + public int MaxConcurrentActivities { get; set; } = 100; + + /// + /// Gets or sets the maximum number of declaration attempts made on transient failures. + /// + public int DeclarationRetryMaxAttempts { get; set; } = 5; + + /// + /// Gets or sets the delay between declaration retry attempts. + /// + public TimeSpan DeclarationRetryDelay { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Gets or sets the interval used to refresh live worker capacity while the registration stream is open. + /// + public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(2); +} diff --git a/src/Grpc/serverless_activities_service.proto b/src/Grpc/serverless_activities_service.proto index a590427f..fc27c0e7 100644 --- a/src/Grpc/serverless_activities_service.proto +++ b/src/Grpc/serverless_activities_service.proto @@ -5,27 +5,32 @@ syntax = "proto3"; package microsoft.durabletask.serverless; +import "google/protobuf/timestamp.proto"; + option csharp_namespace = "Microsoft.DurableTask.Protobuf.Serverless"; service ServerlessActivities { - // Opens a live remote activity worker session. The first message must be a + // Opens a live serverless activity worker session. The first message must be a // start message with static worker metadata. Heartbeats carry dynamic state // only. Closing the stream deregisters the worker. - rpc ConnectRemoteActivityWorker(stream RemoteActivityWorkerMessage) returns (RemoteActivityWorkerSessionResult); + rpc ConnectServerlessActivityWorker(stream ServerlessActivityWorkerMessage) returns (ServerlessActivityWorkerSessionResult); - // Declares remote activities before any live worker stream exists. This is a + // Declares serverless activities before any live worker stream exists. This is a // configuration contract and does not advertise active worker capacity. - rpc DeclareRemoteActivities(RemoteActivityDeclaration) returns (RemoteActivityDeclarationResult); + rpc DeclareServerlessActivities(ServerlessActivityDeclaration) returns (ServerlessActivityDeclarationResult); + + // Streams best-effort stdout/stderr log lines from an ADC sandbox. + rpc StreamSandboxLogs(SandboxLogStreamRequest) returns (stream SandboxLogLine); } -message RemoteActivityWorkerMessage { +message ServerlessActivityWorkerMessage { oneof message { - RemoteActivityWorkerStart start = 1; - RemoteActivityWorkerHeartbeat heartbeat = 2; + ServerlessActivityWorkerStart start = 1; + ServerlessActivityWorkerHeartbeat heartbeat = 2; } } -message RemoteActivityWorkerStart { +message ServerlessActivityWorkerStart { string task_hub = 1; string worker_instance_id = 2; int32 max_activities_count = 3; @@ -36,30 +41,53 @@ message RemoteActivityWorkerStart { string worker_profile_id = 6; } -message RemoteActivityWorkerHeartbeat { +message ServerlessActivityWorkerHeartbeat { int32 active_activities_count = 1; } -message RemoteActivityWorkerSessionResult { +message ServerlessActivityWorkerSessionResult { bool accepted = 1; string message = 2; } -message RemoteActivityDeclaration { - string task_hub = 1; +message ServerlessActivityDeclaration { + reserved 1; string worker_profile_id = 2; repeated string activity_names = 3; - RemoteActivityImage image = 4; + ServerlessActivityImage image = 4; map environment_variables = 5; int32 max_concurrent_activities = 6; + ServerlessActivityResources resources = 7; + repeated string entrypoint = 8; + repeated string cmd = 9; + string launch_command = 10; } -message RemoteActivityImage { +message ServerlessActivityImage { string image_ref = 1; bool public_pull = 2; } -message RemoteActivityDeclarationResult { +message ServerlessActivityResources { + string cpu = 1; + string memory = 2; +} + +message ServerlessActivityDeclarationResult { +} + +message SandboxLogStreamRequest { + string sandbox_id = 1; + int32 tail = 2; +} + +message SandboxLogLine { + string sandbox_id = 1; + google.protobuf.Timestamp timestamp = 2; + string stream = 3; + string tag = 4; + string message = 5; + string raw_line = 6; } // Compute substrate executing the activity worker. diff --git a/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs b/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs index 55a427ea..2b832a54 100644 --- a/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs +++ b/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs @@ -7,14 +7,10 @@ using System.Threading; using Azure.Core; using Grpc.Net.Client; -using Microsoft.DurableTask.Protobuf.Serverless; -using Microsoft.DurableTask.Worker.AzureManaged.Serverless; using Microsoft.DurableTask.Worker.Grpc; using Microsoft.DurableTask.Worker.Grpc.Internal; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Microsoft.DurableTask.Worker.AzureManaged; @@ -24,87 +20,6 @@ namespace Microsoft.DurableTask.Worker.AzureManaged; /// public static class DurableTaskSchedulerWorkerExtensions { - /// - /// Declares remote activities and configures the local worker to exclude them from local execution. - /// - /// The Durable Task worker builder to configure. - /// Optional callback to configure remote activity declaration behavior. - /// The original builder, for call chaining. - public static IDurableTaskWorkerBuilder DeclareRemoteActivities( - this IDurableTaskWorkerBuilder builder, - Action? configure = null) - { - Check.NotNull(builder); - - builder.Services.AddOptions(builder.Name) - .Configure(configure ?? (_ => { })) - .PostConfigure>((options, schedulerOptions) => - { - ApplyTaskHubDefault(options, schedulerOptions.Get(builder.Name).TaskHubName); - ApplyRemoteActivityEnvironmentOverrides(options); - }); - - builder.Services.AddOptions(builder.Name) - .PostConfigure>( - (filters, remoteActivityOptions) => - { - RemoteActivityOptions options = remoteActivityOptions.Get(builder.Name); - string[] activityNames = RemoteActivityConfiguration.ResolveActivityNames(options.ActivityNames); - if (activityNames.Length == 0) - { - return; - } - - filters.ExcludedActivities = MergeActivityFilters(filters.ExcludedActivities, activityNames); - }); - - builder.Services.AddSingleton(sp => CreateRemoteActivityDeclarationHostedService(sp, builder.Name)); - return builder; - } - - /// - /// Configures this worker as a sandbox remote activity worker and registers live capacity with DTS. - /// - /// The Durable Task worker builder to configure. - /// Optional callback to configure remote activity worker behavior. - /// The original builder, for call chaining. - public static IDurableTaskWorkerBuilder UseRemoteActivityWorker( - this IDurableTaskWorkerBuilder builder, - Action? configure = null) - { - Check.NotNull(builder); - - builder.Services.AddOptions(builder.Name) - .Configure(configure ?? (_ => { })) - .PostConfigure>((options, schedulerOptions) => - { - ApplyTaskHubDefault(options, schedulerOptions.Get(builder.Name).TaskHubName); - ApplyRemoteActivityWorkerEnvironmentOverrides(options); - }); - - builder.Services.AddOptions(builder.Name) - .PostConfigure>( - (filters, remoteActivityWorkerOptions) => - { - RemoteActivityWorkerOptions options = remoteActivityWorkerOptions.Get(builder.Name); - string[] activityNames = RemoteActivityConfiguration.ResolveActivityNames(options.ActivityNames); - if (activityNames.Length == 0) - { - return; - } - - filters.Orchestrations = []; - filters.Activities = activityNames - .Select(static name => new DurableTaskWorkerWorkItemFilters.ActivityFilter { Name = name }) - .ToArray(); - filters.ExcludedActivities = []; - filters.Entities = []; - }); - - builder.Services.AddSingleton(sp => CreateRemoteActivityWorkerRegistrationHostedService(sp, builder.Name)); - return builder; - } - /// /// Configures Durable Task worker to use the Azure Durable Task Scheduler service. /// @@ -188,134 +103,6 @@ static void ConfigureSchedulerOptions( builder.UseGrpc(_ => { }); } - static RemoteActivityDeclarationHostedService CreateRemoteActivityDeclarationHostedService( - IServiceProvider services, - string builderName) - { - RemoteActivityOptions options = services.GetRequiredService>().Get(builderName); - ILoggerFactory loggerFactory = services.GetRequiredService(); - - return new RemoteActivityDeclarationHostedService( - CreateServerlessActivitiesClient(services, builderName), - options, - loggerFactory.CreateLogger()); - } - - static RemoteActivityWorkerRegistrationHostedService CreateRemoteActivityWorkerRegistrationHostedService(IServiceProvider services, string builderName) - { - RemoteActivityWorkerOptions options = services.GetRequiredService>().Get(builderName); - ILoggerFactory loggerFactory = services.GetRequiredService(); - IHostApplicationLifetime? lifetime = services.GetService(); - - return new RemoteActivityWorkerRegistrationHostedService( - CreateServerlessActivitiesClient(services, builderName), - options, - loggerFactory.CreateLogger(), - lifetime); - } - - static ServerlessActivitiesClientAdapter CreateServerlessActivitiesClient(IServiceProvider services, string builderName) - { - GrpcDurableTaskWorkerOptions options = services.GetRequiredService>().Get(builderName); - if (options.CallInvoker is { } callInvoker) - { - return new ServerlessActivitiesClientAdapter(new ServerlessActivities.ServerlessActivitiesClient(callInvoker)); - } - - if (options.Channel is { } channel) - { - return new ServerlessActivitiesClientAdapter(new ServerlessActivities.ServerlessActivitiesClient(channel.CreateCallInvoker())); - } - - throw new InvalidOperationException("Azure Managed remote activities require a configured gRPC channel or call invoker."); - } - - static void ApplyTaskHubDefault(RemoteActivityOptions options, string taskHubName) - { - if (string.IsNullOrWhiteSpace(options.TaskHub) && !string.IsNullOrWhiteSpace(taskHubName)) - { - options.TaskHub = taskHubName; - } - } - - static void ApplyTaskHubDefault(RemoteActivityWorkerOptions options, string taskHubName) - { - if (string.IsNullOrWhiteSpace(options.TaskHub) && !string.IsNullOrWhiteSpace(taskHubName)) - { - options.TaskHub = taskHubName; - } - } - - static void ApplyRemoteActivityEnvironmentOverrides(RemoteActivityOptions options) - { - ApplyActivityNameEnvironmentOverride(options.ActivityNames); - ApplyWorkerProfileEnvironmentOverride(profile => options.WorkerProfileId = profile); - - string? image = Environment.GetEnvironmentVariable("DTS_REMOTE_ACTIVITY_IMAGE"); - if (!string.IsNullOrWhiteSpace(image)) - { - options.ContainerImage = image; - } - } - - static void ApplyRemoteActivityWorkerEnvironmentOverrides(RemoteActivityWorkerOptions options) - { - ApplyActivityNameEnvironmentOverride(options.ActivityNames); - ApplyWorkerProfileEnvironmentOverride(profile => options.WorkerProfileId = profile); - - if (int.TryParse(Environment.GetEnvironmentVariable("DTS_SERVERLESS_MAX_ACTIVITIES"), out int maxActivities) && maxActivities > 0) - { - options.MaxConcurrentActivities = maxActivities; - } - } - - static void ApplyActivityNameEnvironmentOverride(ICollection activityNames) - { - string? remoteActivities = Environment.GetEnvironmentVariable("DTS_REMOTE_ACTIVITIES"); - if (remoteActivities is null) - { - return; - } - - activityNames.Clear(); - foreach (string name in remoteActivities - .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) - .Distinct(StringComparer.Ordinal)) - { - activityNames.Add(name); - } - } - - static void ApplyWorkerProfileEnvironmentOverride(Action setWorkerProfileId) - { - string? workerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID"); - if (!string.IsNullOrWhiteSpace(workerProfileId)) - { - setWorkerProfileId(workerProfileId.Trim()); - } - } - - static DurableTaskWorkerWorkItemFilters.ActivityFilter[] MergeActivityFilters( - IReadOnlyList existingFilters, - IEnumerable activityNames) - { - Dictionary merged = new(StringComparer.OrdinalIgnoreCase); - foreach (DurableTaskWorkerWorkItemFilters.ActivityFilter filter in existingFilters) - { - if (!string.IsNullOrWhiteSpace(filter.Name)) - { - merged[filter.Name] = filter; - } - } - - foreach (string activityName in activityNames) - { - merged[activityName] = new DurableTaskWorkerWorkItemFilters.ActivityFilter { Name = activityName }; - } - - return merged.Values.ToArray(); - } - /// /// Configuration class that sets up gRPC channels for worker options /// using the provided Durable Task Scheduler options. diff --git a/src/Worker/AzureManaged/Serverless/RemoteActivityOptions.cs b/src/Worker/AzureManaged/Serverless/RemoteActivityOptions.cs deleted file mode 100644 index 814d6621..00000000 --- a/src/Worker/AzureManaged/Serverless/RemoteActivityOptions.cs +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; - -/// -/// Options for declaring remote activities and the image DTS should use to run them. -/// -public sealed class RemoteActivityOptions -{ - /// - /// Default worker profile ID used when no profile is specified. - /// - internal const string DefaultWorkerProfileId = "default"; - - /// - /// Gets the remote activity names to declare. - /// - public IList ActivityNames { get; } = new List(); - - /// - /// Gets or sets the task hub that owns this declaration. - /// - public string TaskHub { get; set; } = string.Empty; - - /// - /// Gets or sets the worker profile ID that owns this remote activity declaration. - /// - public string WorkerProfileId { get; set; } = DefaultWorkerProfileId; - - /// - /// Gets or sets the full container image reference for the remote worker image. - /// - public string? ContainerImage { get; set; } - - /// - /// Gets or sets the registry server for the remote worker image. - /// - public string? RegistryServer { get; set; } - - /// - /// Gets or sets the repository for the remote worker image. - /// - public string? Repository { get; set; } - - /// - /// Gets or sets the tag for the remote worker image. - /// - public string? Tag { get; set; } - - /// - /// Gets or sets the digest for the remote worker image. - /// - public string? ImageDigest { get; set; } - - /// - /// Gets or sets a value indicating whether the image is publicly pullable. Private preview requires this to be true. - /// - public bool PublicPull { get; set; } = true; - - /// - /// Gets environment variables DTS should provide to remote workers created from this declaration. - /// - public IDictionary EnvironmentVariables { get; } = new Dictionary(StringComparer.Ordinal); - - /// - /// Gets or sets the maximum concurrent activities expected from each remote worker. - /// - public int MaxConcurrentActivities { get; set; } = 100; - - /// - /// Gets or sets the maximum number of declaration attempts made on transient failures. - /// - public int DeclarationRetryMaxAttempts { get; set; } = 5; - - /// - /// Gets or sets the delay between declaration retry attempts. - /// - public TimeSpan DeclarationRetryDelay { get; set; } = TimeSpan.FromSeconds(1); -} diff --git a/src/Worker/AzureManaged/Serverless/RemoteActivityWorkerOptions.cs b/src/Worker/AzureManaged/Serverless/RemoteActivityWorkerOptions.cs deleted file mode 100644 index ee8bf78d..00000000 --- a/src/Worker/AzureManaged/Serverless/RemoteActivityWorkerOptions.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; - -/// -/// Options for a sandbox worker that registers live remote activity capacity with DTS. -/// -public sealed class RemoteActivityWorkerOptions -{ - /// - /// Gets the remote activity names this worker should execute. - /// - public IList ActivityNames { get; } = new List(); - - /// - /// Gets or sets the task hub this worker connects to. - /// - public string TaskHub { get; set; } = string.Empty; - - /// - /// Gets or sets the worker profile ID this worker registers capacity for. - /// - public string WorkerProfileId { get; set; } = RemoteActivityOptions.DefaultWorkerProfileId; - - /// - /// Gets the unique worker instance identifier. - /// - public string WorkerInstanceId { get; } = Guid.NewGuid().ToString("N"); - - /// - /// Gets or sets the maximum number of concurrent activities this worker can accept. - /// - public int MaxConcurrentActivities { get; set; } = 100; - - /// - /// Gets or sets the interval used to refresh live worker capacity while the registration stream is open. - /// - public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(2); -} diff --git a/test/Extensions/AzureManagedServerless.Tests/AzureManagedServerless.Tests.csproj b/test/Extensions/AzureManagedServerless.Tests/AzureManagedServerless.Tests.csproj new file mode 100644 index 00000000..a03e480e --- /dev/null +++ b/test/Extensions/AzureManagedServerless.Tests/AzureManagedServerless.Tests.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + + + + + + + + + + + + diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs new file mode 100644 index 00000000..104cb30a --- /dev/null +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Microsoft.DurableTask.Protobuf.Serverless; +using Xunit; + +namespace Microsoft.DurableTask.Client.AzureManaged.Tests; + +public class ServerlessActivitiesClientExtensionsTests +{ + const string TaskHub = "testhub"; + + [Fact] + public async Task StreamSandboxLogsAsync_SendsRequestAndMapsLines() + { + // Arrange + DateTimeOffset timestamp = new(2026, 5, 14, 10, 30, 0, TimeSpan.Zero); + RecordingServerlessLogCallInvoker callInvoker = new( + new SandboxLogLine + { + SandboxId = "sandbox-1", + Timestamp = timestamp.ToTimestamp(), + Stream = "stdout", + Tag = "worker", + Message = "hello from serverless", + RawLine = "2026-05-14T10:30:00Z stdout worker hello from serverless", + }); + ServerlessActivities.ServerlessActivitiesClient client = new(callInvoker); + + // Act + List lines = []; + await foreach (ServerlessSandboxLogLine line in client.StreamSandboxLogsAsync( + "sandbox-1", + TaskHub, + tail: 42)) + { + lines.Add(line); + } + + // Assert + callInvoker.Request.Should().NotBeNull(); + callInvoker.Request!.SandboxId.Should().Be("sandbox-1"); + callInvoker.Request.Tail.Should().Be(42); + callInvoker.Headers.Should().Contain(header => header.Key == "taskhub" && header.Value == TaskHub); + callInvoker.DisposeCount.Should().Be(1); + + ServerlessSandboxLogLine mapped = lines.Should().ContainSingle().Subject; + mapped.SandboxId.Should().Be("sandbox-1"); + mapped.Timestamp.Should().Be(timestamp); + mapped.Stream.Should().Be("stdout"); + mapped.Tag.Should().Be("worker"); + mapped.Message.Should().Be("hello from serverless"); + mapped.RawLine.Should().Be("2026-05-14T10:30:00Z stdout worker hello from serverless"); + } + + [Fact] + public async Task StreamSandboxLogsAsync_WithoutExplicitTaskHub_UsesConfiguredChannelMetadata() + { + // Arrange + RecordingServerlessLogCallInvoker callInvoker = new(); + ServerlessActivities.ServerlessActivitiesClient client = new(callInvoker); + + // Act + await foreach (ServerlessSandboxLogLine _ in client.StreamSandboxLogsAsync("sandbox-1", tail: 42)) + { + } + + // Assert + callInvoker.Headers.Should().NotContain(header => header.Key == "taskhub"); + } + + [Theory] + [InlineData(-1)] + [InlineData(301)] + public async Task StreamSandboxLogsAsync_WithInvalidTail_ThrowsArgumentOutOfRangeException(int tail) + { + // Arrange + ServerlessActivities.ServerlessActivitiesClient client = new(new RecordingServerlessLogCallInvoker()); + + // Act + Func action = async () => + { + await foreach (ServerlessSandboxLogLine _ in client.StreamSandboxLogsAsync( + "sandbox-1", + TaskHub, + tail)) + { + } + }; + + // Assert + await action.Should().ThrowAsync() + .WithParameterName("tail"); + } + + sealed class RecordingServerlessLogCallInvoker : CallInvoker + { + readonly SandboxLogStreamReader responseStream; + + public RecordingServerlessLogCallInvoker(params SandboxLogLine[] lines) + { + this.responseStream = new SandboxLogStreamReader(lines); + } + + public SandboxLogStreamRequest? Request { get; private set; } + + public Metadata Headers { get; private set; } = []; + + public int DisposeCount { get; private set; } + + public override TResponse BlockingUnaryCall( + Method method, + string? host, + CallOptions options, + TRequest request) + { + throw new NotSupportedException(); + } + + public override AsyncUnaryCall AsyncUnaryCall( + Method method, + string? host, + CallOptions options, + TRequest request) + { + throw new NotSupportedException(); + } + + public override AsyncServerStreamingCall AsyncServerStreamingCall( + Method method, + string? host, + CallOptions options, + TRequest request) + { + method.FullName.Should().EndWith("/StreamSandboxLogs"); + this.Request = (SandboxLogStreamRequest)(object)request; + this.Headers = options.Headers ?? []; + + return new AsyncServerStreamingCall( + (IAsyncStreamReader)(object)this.responseStream, + Task.FromResult(new Metadata()), + () => new Status(StatusCode.OK, string.Empty), + () => new Metadata(), + () => this.DisposeCount++); + } + + public override AsyncClientStreamingCall AsyncClientStreamingCall( + Method method, + string? host, + CallOptions options) + { + throw new NotSupportedException(); + } + + public override AsyncDuplexStreamingCall AsyncDuplexStreamingCall( + Method method, + string? host, + CallOptions options) + { + throw new NotSupportedException(); + } + } + + sealed class SandboxLogStreamReader : IAsyncStreamReader + { + readonly Queue lines; + + public SandboxLogStreamReader(IEnumerable lines) + { + this.lines = new Queue(lines); + } + + public SandboxLogLine Current { get; private set; } = new(); + + public Task MoveNext(CancellationToken cancellationToken) + { + if (this.lines.Count == 0) + { + return Task.FromResult(false); + } + + this.Current = this.lines.Dequeue(); + return Task.FromResult(true); + } + } +} diff --git a/test/Worker/AzureManaged.Tests/RemoteActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs similarity index 63% rename from test/Worker/AzureManaged.Tests/RemoteActivitiesTests.cs rename to test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index 4af28d36..b7b80964 100644 --- a/test/Worker/AzureManaged.Tests/RemoteActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -14,57 +14,69 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Tests; -public class RemoteActivitiesTests +public class ServerlessActivitiesTests { const string TaskHub = "testhub"; [Fact] - public async Task RemoteActivityDeclarationHostedService_SendsDeclarationPayload() + public async Task ServerlessActivityDeclarationHostedService_SendsDeclarationPayload() { // Arrange - RemoteActivityOptions options = new() + ServerlessOptions options = new() { TaskHub = TaskHub, WorkerProfileId = "profile-a", ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", + Cpu = "500m", + Memory = "1024Mi", + LaunchCommand = "cd /app && dotnet DemoWorker.dll", MaxConcurrentActivities = 7, }; options.ActivityNames.Add("RemoteHello"); options.EnvironmentVariables.Add("CUSTOM_SETTING", "enabled"); + options.Entrypoint.Add("/usr/bin/tini"); + options.Entrypoint.Add("--"); + options.Cmd.Add("dotnet"); + options.Cmd.Add("/app/DemoWorker.dll"); FakeServerlessActivitiesClient client = new(); - RemoteActivityDeclarationHostedService service = new( + ServerlessActivityDeclarationHostedService service = new( client, options, - NullLogger.Instance); + NullLogger.Instance); // Act await service.StartAsync(CancellationToken.None); // Assert - RemoteActivityDeclaration declaration = client.Declarations.Should().ContainSingle().Subject; - declaration.TaskHub.Should().Be(TaskHub); + ServerlessActivityDeclaration declaration = client.Declarations.Should().ContainSingle().Subject; + client.DeclarationTaskHubs.Should().Equal(TaskHub); declaration.WorkerProfileId.Should().Be("profile-a"); declaration.ActivityNames.Should().Equal("RemoteHello"); declaration.Image.ImageRef.Should().Be("mcr.microsoft.com/durabletask/demo-worker:1.0"); declaration.Image.PublicPull.Should().BeTrue(); + declaration.Resources.Cpu.Should().Be("500m"); + declaration.Resources.Memory.Should().Be("1024Mi"); declaration.EnvironmentVariables.Should().ContainKey("CUSTOM_SETTING").WhoseValue.Should().Be("enabled"); + declaration.Entrypoint.Should().Equal("/usr/bin/tini", "--"); + declaration.Cmd.Should().Equal("dotnet", "/app/DemoWorker.dll"); + declaration.LaunchCommand.Should().Be("cd /app && dotnet DemoWorker.dll"); declaration.MaxConcurrentActivities.Should().Be(7); } [Fact] - public async Task RemoteActivityDeclarationHostedService_SkipsDeclarationWhenNamesAreEmpty() + public async Task ServerlessActivityDeclarationHostedService_SkipsDeclarationWhenNamesAreEmpty() { // Arrange - RemoteActivityOptions options = new() + ServerlessOptions options = new() { TaskHub = TaskHub, ContainerImage = "example.com/repo/worker:latest", }; FakeServerlessActivitiesClient client = new(); - RemoteActivityDeclarationHostedService service = new( + ServerlessActivityDeclarationHostedService service = new( client, options, - NullLogger.Instance); + NullLogger.Instance); // Act await service.StartAsync(CancellationToken.None); @@ -74,10 +86,10 @@ public async Task RemoteActivityDeclarationHostedService_SkipsDeclarationWhenNam } [Fact] - public async Task RemoteActivityDeclarationHostedService_RetriesTransientFailures() + public async Task ServerlessActivityDeclarationHostedService_RetriesTransientFailures() { // Arrange - RemoteActivityOptions options = new() + ServerlessOptions options = new() { TaskHub = TaskHub, ContainerImage = "example.com/repo/worker@sha256:abc", @@ -86,10 +98,10 @@ public async Task RemoteActivityDeclarationHostedService_RetriesTransientFailure }; options.ActivityNames.Add("RemoteHello"); FakeServerlessActivitiesClient client = new() { TransientDeclarationFailures = 1 }; - RemoteActivityDeclarationHostedService service = new( + ServerlessActivityDeclarationHostedService service = new( client, options, - NullLogger.Instance); + NullLogger.Instance); // Act await service.StartAsync(CancellationToken.None); @@ -100,31 +112,31 @@ public async Task RemoteActivityDeclarationHostedService_RetriesTransientFailure } [Fact] - public async Task RemoteActivityDeclarationHostedService_RejectsPrivatePullImages() + public async Task ServerlessActivityDeclarationHostedService_RejectsPrivatePullImages() { // Arrange - RemoteActivityOptions options = new() + ServerlessOptions options = new() { TaskHub = TaskHub, ContainerImage = "example.com/repo/worker:latest", PublicPull = false, }; options.ActivityNames.Add("RemoteHello"); - RemoteActivityDeclarationHostedService service = new( + ServerlessActivityDeclarationHostedService service = new( new FakeServerlessActivitiesClient(), options, - NullLogger.Instance); + NullLogger.Instance); // Act Func action = () => service.StartAsync(CancellationToken.None); // Assert await action.Should().ThrowAsync() - .WithMessage("Remote activity images must be publicly pullable for private preview."); + .WithMessage("Serverless activity images must be publicly pullable for private preview."); } [Fact] - public async Task RemoteActivityWorkerRegistrationHostedService_SendsLiveWorkerMetadataWithoutActivityCatalog() + public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWorkerMetadataWithoutActivityCatalog() { // Arrange string? originalSubstrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); @@ -134,8 +146,9 @@ public async Task RemoteActivityWorkerRegistrationHostedService_SendsLiveWorkerM try { - RemoteActivityWorkerOptions options = new() + ServerlessOptions options = new() { + Mode = ServerlessMode.ServerlessInclude, TaskHub = TaskHub, WorkerProfileId = "profile-a", MaxConcurrentActivities = 3, @@ -143,18 +156,19 @@ public async Task RemoteActivityWorkerRegistrationHostedService_SendsLiveWorkerM }; options.ActivityNames.Add("RemoteHello"); FakeServerlessActivitiesClient client = new(); - RemoteActivityWorkerRegistrationHostedService service = new( + ServerlessActivityWorkerRegistrationHostedService service = new( client, options, - NullLogger.Instance); + NullLogger.Instance); // Act await service.StartAsync(CancellationToken.None); await service.StopAsync(CancellationToken.None); // Assert - RemoteActivityWorkerMessage message = client.Session.Messages.Should().ContainSingle().Subject; - RemoteActivityWorkerStart start = message.Start; + client.SessionTaskHubs.Should().Equal(TaskHub); + ServerlessActivityWorkerMessage message = client.Session.Messages.Should().ContainSingle().Subject; + ServerlessActivityWorkerStart start = message.Start; start.TaskHub.Should().Be(TaskHub); start.WorkerProfileId.Should().Be("profile-a"); start.MaxActivitiesCount.Should().Be(3); @@ -169,17 +183,17 @@ public async Task RemoteActivityWorkerRegistrationHostedService_SendsLiveWorkerM } [Fact] - public async Task DeclareRemoteActivities_ConfiguresLocalWorkerExclusionFilter() + public async Task UseServerlessActivities_LocalExclude_ConfiguresLocalWorkerExclusionFilter() { // Arrange - using EnvironmentVariableScope remoteActivities = new("DTS_REMOTE_ACTIVITIES", null); + using EnvironmentVariableScope serverlessActivities = new("DTS_SERVERLESS_ACTIVITIES", null); ServiceCollection services = new(); Mock mockBuilder = new(); - mockBuilder.Setup(b => b.Services).Returns(services); - mockBuilder.Setup(b => b.Name).Returns(Options.DefaultName); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - mockBuilder.Object.DeclareRemoteActivities(options => + mockBuilder.Object.UseServerlessActivities(options => { options.TaskHub = TaskHub; options.ContainerImage = "example.com/repo/worker:latest"; @@ -195,17 +209,17 @@ public async Task DeclareRemoteActivities_ConfiguresLocalWorkerExclusionFilter() } [Fact] - public async Task DeclareRemoteActivities_DoesNotConfigureFilterWhenActivityNamesAreEmpty() + public async Task UseServerlessActivities_DoesNotConfigureFilterWhenActivityNamesAreEmpty() { // Arrange - using EnvironmentVariableScope remoteActivities = new("DTS_REMOTE_ACTIVITIES", null); + using EnvironmentVariableScope serverlessActivities = new("DTS_SERVERLESS_ACTIVITIES", null); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - mockBuilder.Object.DeclareRemoteActivities(options => + mockBuilder.Object.UseServerlessActivities(options => { options.TaskHub = TaskHub; options.ContainerImage = "example.com/repo/worker:latest"; @@ -220,18 +234,19 @@ public async Task DeclareRemoteActivities_DoesNotConfigureFilterWhenActivityName } [Fact] - public async Task UseRemoteActivityWorker_ConfiguresRemoteActivityWorkerFilter() + public async Task UseServerlessActivities_ServerlessInclude_ConfiguresServerlessActivityWorkerFilter() { // Arrange - using EnvironmentVariableScope remoteActivities = new("DTS_REMOTE_ACTIVITIES", null); + using EnvironmentVariableScope serverlessActivities = new("DTS_SERVERLESS_ACTIVITIES", null); ServiceCollection services = new(); Mock mockBuilder = new(); - mockBuilder.Setup(b => b.Services).Returns(services); - mockBuilder.Setup(b => b.Name).Returns(Options.DefaultName); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - mockBuilder.Object.UseRemoteActivityWorker(options => + mockBuilder.Object.UseServerlessActivities(options => { + options.Mode = ServerlessMode.ServerlessInclude; options.TaskHub = TaskHub; options.ActivityNames.Add("RemoteHello"); }); @@ -246,42 +261,23 @@ public async Task UseRemoteActivityWorker_ConfiguresRemoteActivityWorkerFilter() filters.Entities.Should().BeEmpty(); } - [Fact] - public async Task UseRemoteActivityWorker_DoesNotConfigureFilterWhenActivityNamesAreEmpty() - { - // Arrange - using EnvironmentVariableScope remoteActivities = new("DTS_REMOTE_ACTIVITIES", null); - ServiceCollection services = new(); - Mock mockBuilder = new(); - mockBuilder.Setup(builder => builder.Services).Returns(services); - mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); - - // Act - mockBuilder.Object.UseRemoteActivityWorker(options => - { - options.TaskHub = TaskHub; - }); - - await using ServiceProvider provider = services.BuildServiceProvider(); - DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); - - // Assert - filters.Activities.Should().BeEmpty(); - filters.ExcludedActivities.Should().BeEmpty(); - } - sealed class FakeServerlessActivitiesClient : IServerlessActivitiesClient { public int TransientDeclarationFailures { get; init; } public int DeclarationAttempts { get; private set; } - public List Declarations { get; } = []; + public List Declarations { get; } = []; + + public List DeclarationTaskHubs { get; } = []; - public FakeRemoteActivityWorkerSession Session { get; } = new(); + public List SessionTaskHubs { get; } = []; - public Task DeclareRemoteActivitiesAsync( - RemoteActivityDeclaration declaration, + public FakeServerlessActivityWorkerSession Session { get; } = new(); + + public Task DeclareServerlessActivitiesAsync( + ServerlessActivityDeclaration declaration, + string taskHub, CancellationToken cancellationToken) { this.DeclarationAttempts++; @@ -290,18 +286,23 @@ public Task DeclareRemoteActivitiesAsync( throw new RpcException(new Status(StatusCode.Unavailable, "transient")); } + this.DeclarationTaskHubs.Add(taskHub); this.Declarations.Add(declaration.Clone()); - return Task.FromResult(new RemoteActivityDeclarationResult()); + return Task.FromResult(new ServerlessActivityDeclarationResult()); } - public IRemoteActivityWorkerSession OpenRemoteActivityWorkerSession(CancellationToken cancellationToken) => this.Session; + public IServerlessActivityWorkerSession OpenServerlessActivityWorkerSession(string taskHub, CancellationToken cancellationToken) + { + this.SessionTaskHubs.Add(taskHub); + return this.Session; + } } - sealed class FakeRemoteActivityWorkerSession : IRemoteActivityWorkerSession + sealed class FakeServerlessActivityWorkerSession : IServerlessActivityWorkerSession { - public List Messages { get; } = []; + public List Messages { get; } = []; - public Task WriteMessageAsync(RemoteActivityWorkerMessage message) + public Task WriteMessageAsync(ServerlessActivityWorkerMessage message) { this.Messages.Add(message.Clone()); return Task.CompletedTask; From 8d96663ec7fd57f0807ad5f903b01db21eafe8d6 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 14 May 2026 21:58:39 -0700 Subject: [PATCH 04/81] remove launchcommand --- ...TaskSchedulerServerlessWorkerExtensions.cs | 19 ++++------- .../ServerlessActivityConfiguration.cs | 1 - .../Worker/Serverless/ServerlessOptions.cs | 5 --- src/Grpc/serverless_activities_service.proto | 3 +- .../ServerlessActivitiesTests.cs | 34 +++++++++++++++++-- 5 files changed, 41 insertions(+), 21 deletions(-) diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs index b3050fbc..13525340 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -124,13 +124,14 @@ static void ApplyTaskHubDefault(ServerlessOptions options, string taskHubName) static void ApplyServerlessEnvironmentOverrides(ServerlessOptions options) { - string? mode = Environment.GetEnvironmentVariable("DTS_SERVERLESS_MODE"); - if (!string.IsNullOrWhiteSpace(mode)) + // Auto-detect worker mode from DTS_SUBSTRATE, which the backend injects when + // launching a sandbox. This removes the need for callers to manually set Mode + // or inject DTS_SERVERLESS_MODE into the sandbox environment. + string? substrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); + if (string.Equals(substrate, "Sandbox", StringComparison.OrdinalIgnoreCase) + || string.Equals(substrate, "AcaSessionPool", StringComparison.OrdinalIgnoreCase)) { - options.Mode = string.Equals(mode, "serverless-worker", StringComparison.OrdinalIgnoreCase) - || string.Equals(mode, nameof(ServerlessMode.ServerlessInclude), StringComparison.OrdinalIgnoreCase) - ? ServerlessMode.ServerlessInclude - : ServerlessMode.LocalExclude; + options.Mode = ServerlessMode.ServerlessInclude; } ApplyActivityNameEnvironmentOverride(options.ActivityNames); @@ -154,12 +155,6 @@ static void ApplyServerlessEnvironmentOverrides(ServerlessOptions options) options.Memory = memory.Trim(); } - string? launchCommand = Environment.GetEnvironmentVariable("DTS_SERVERLESS_LAUNCH_COMMAND"); - if (!string.IsNullOrWhiteSpace(launchCommand)) - { - options.LaunchCommand = launchCommand; - } - if (int.TryParse(Environment.GetEnvironmentVariable("DTS_SERVERLESS_MAX_ACTIVITIES"), out int maxActivities) && maxActivities > 0) { options.MaxConcurrentActivities = maxActivities; diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs index 66239428..e278fcf5 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs @@ -54,7 +54,6 @@ public static Proto.ServerlessActivityDeclaration BuildDeclaration(ServerlessOpt WorkerProfileId = workerProfileId, Image = BuildImage(options), Resources = BuildResources(options), - LaunchCommand = options.LaunchCommand ?? string.Empty, MaxConcurrentActivities = options.MaxConcurrentActivities, }; diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs index 4551797a..512547ea 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs @@ -104,11 +104,6 @@ public sealed class ServerlessOptions /// public IList Cmd { get; } = new List(); - /// - /// Gets or sets the shell command exec'd after the sandbox reaches Running. - /// - public string LaunchCommand { get; set; } = string.Empty; - /// /// Gets the unique worker instance identifier. /// diff --git a/src/Grpc/serverless_activities_service.proto b/src/Grpc/serverless_activities_service.proto index fc27c0e7..dba51559 100644 --- a/src/Grpc/serverless_activities_service.proto +++ b/src/Grpc/serverless_activities_service.proto @@ -52,6 +52,8 @@ message ServerlessActivityWorkerSessionResult { message ServerlessActivityDeclaration { reserved 1; + reserved 10; + reserved "launch_command"; string worker_profile_id = 2; repeated string activity_names = 3; ServerlessActivityImage image = 4; @@ -60,7 +62,6 @@ message ServerlessActivityDeclaration { ServerlessActivityResources resources = 7; repeated string entrypoint = 8; repeated string cmd = 9; - string launch_command = 10; } message ServerlessActivityImage { diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index b7b80964..3f022fa4 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -18,6 +18,13 @@ public class ServerlessActivitiesTests { const string TaskHub = "testhub"; + [Fact] + public void ServerlessDeclarationContract_DoesNotExposeLaunchCommand() + { + typeof(ServerlessOptions).GetProperty("LaunchCommand").Should().BeNull(); + typeof(ServerlessActivityDeclaration).GetProperty("LaunchCommand").Should().BeNull(); + } + [Fact] public async Task ServerlessActivityDeclarationHostedService_SendsDeclarationPayload() { @@ -29,7 +36,6 @@ public async Task ServerlessActivityDeclarationHostedService_SendsDeclarationPay ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", Cpu = "500m", Memory = "1024Mi", - LaunchCommand = "cd /app && dotnet DemoWorker.dll", MaxConcurrentActivities = 7, }; options.ActivityNames.Add("RemoteHello"); @@ -59,10 +65,34 @@ public async Task ServerlessActivityDeclarationHostedService_SendsDeclarationPay declaration.EnvironmentVariables.Should().ContainKey("CUSTOM_SETTING").WhoseValue.Should().Be("enabled"); declaration.Entrypoint.Should().Equal("/usr/bin/tini", "--"); declaration.Cmd.Should().Equal("dotnet", "/app/DemoWorker.dll"); - declaration.LaunchCommand.Should().Be("cd /app && dotnet DemoWorker.dll"); declaration.MaxConcurrentActivities.Should().Be(7); } + [Fact] + public async Task ServerlessActivityDeclarationHostedService_OmitsEntrypointAndCmdByDefault() + { + // Arrange + ServerlessOptions options = new() + { + TaskHub = TaskHub, + ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", + }; + options.ActivityNames.Add("RemoteHello"); + FakeServerlessActivitiesClient client = new(); + ServerlessActivityDeclarationHostedService service = new( + client, + options, + NullLogger.Instance); + + // Act + await service.StartAsync(CancellationToken.None); + + // Assert + ServerlessActivityDeclaration declaration = client.Declarations.Should().ContainSingle().Subject; + declaration.Entrypoint.Should().BeEmpty(); + declaration.Cmd.Should().BeEmpty(); + } + [Fact] public async Task ServerlessActivityDeclarationHostedService_SkipsDeclarationWhenNamesAreEmpty() { From 6dc7d23f1f6af5a9402f401086799c88e9defdfc Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 14 May 2026 23:24:14 -0700 Subject: [PATCH 05/81] separate declare from conneect --- ...TaskSchedulerServerlessWorkerExtensions.cs | 127 +++++++++++------- .../Worker/Serverless/ServerlessOptions.cs | 6 +- 2 files changed, 80 insertions(+), 53 deletions(-) diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs index 13525340..4b562311 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -21,55 +21,100 @@ namespace Microsoft.DurableTask.Worker.AzureManaged; public static class DurableTaskSchedulerServerlessWorkerExtensions { /// - /// Configures serverless activity declaration, local exclusion, and serverless worker registration. + /// Declares serverless activities with DTS, excludes them from local execution, and propagates the + /// activity list to sandbox workers via the DTS_SERVERLESS_ACTIVITIES environment variable. + /// Call this on the local coordinator worker — not on the sandbox worker binary. /// /// The Durable Task worker builder to configure. - /// Optional callback to configure serverless activity behavior. + /// Callback to configure serverless activity behavior. /// The original builder, for call chaining. - public static IDurableTaskWorkerBuilder UseServerlessActivities( + public static IDurableTaskWorkerBuilder DeclareServerlessActivities( this IDurableTaskWorkerBuilder builder, - Action? configure = null) + Action configure) + { + Check.NotNull(builder); + Check.NotNull(configure); + + builder.Services.AddOptions(builder.Name) + .Configure(configure) + .PostConfigure>((options, schedulerOptions) => + ApplyTaskHubDefault(options, schedulerOptions.Get(builder.Name).TaskHubName)); + + builder.Services.AddOptions(builder.Name) + .PostConfigure>( + (filters, serverlessOptions) => ExcludeServerlessActivitiesFromLocalExecution(filters, serverlessOptions.Get(builder.Name))); + + builder.Services.AddSingleton(sp => CreateServerlessActivityDeclarationHostedService(sp, builder.Name)); + return builder; + } + + /// + /// Configures this worker as a serverless activity worker that connects to DTS to receive and execute + /// serverless activities. Use this on a dedicated worker binary that runs inside serverless infrastructure. + /// All configuration is read from environment variables injected by the backend and coordinator. + /// + /// + /// + /// This method is for separate worker binaries only. The coordinator uses + /// to declare and provision the serverless activity configuration. + /// + /// + /// Required environment variables (injected automatically by the backend and coordinator): + /// + /// DTS_SUBSTRATE — identifies the sandbox substrate (injected by backend) + /// DTS_SERVERLESS_ACTIVITIES — comma-separated activity names to execute (injected by coordinator) + /// DTS_TASK_HUB — task hub name (injected by coordinator) + /// + /// + /// + /// The Durable Task worker builder to configure. + /// The original builder, for call chaining. + public static IDurableTaskWorkerBuilder UseServerlessWorker(this IDurableTaskWorkerBuilder builder) { Check.NotNull(builder); builder.Services.AddOptions(builder.Name) - .Configure(configure ?? (_ => { })) .PostConfigure>((options, schedulerOptions) => { ApplyTaskHubDefault(options, schedulerOptions.Get(builder.Name).TaskHubName); - ApplyServerlessEnvironmentOverrides(options); + ApplyWorkerEnvironmentOverrides(options); }); builder.Services.AddOptions(builder.Name) .PostConfigure>( - (filters, serverlessOptions) => - { - ServerlessOptions options = serverlessOptions.Get(builder.Name); - string[] activityNames = ServerlessActivityConfiguration.ResolveActivityNames(options.ActivityNames); - if (activityNames.Length == 0) - { - return; - } - - if (options.Mode == ServerlessMode.ServerlessInclude) - { - filters.Orchestrations = []; - filters.Activities = activityNames - .Select(static name => new DurableTaskWorkerWorkItemFilters.ActivityFilter { Name = name }) - .ToArray(); - filters.ExcludedActivities = []; - filters.Entities = []; - return; - } - - filters.ExcludedActivities = MergeActivityFilters(filters.ExcludedActivities, activityNames); - }); + (filters, serverlessOptions) => IncludeOnlyServerlessActivities(filters, serverlessOptions.Get(builder.Name))); - builder.Services.AddSingleton(sp => CreateServerlessActivityDeclarationHostedService(sp, builder.Name)); builder.Services.AddSingleton(sp => CreateServerlessActivityWorkerRegistrationHostedService(sp, builder.Name)); return builder; } + static void ExcludeServerlessActivitiesFromLocalExecution(DurableTaskWorkerWorkItemFilters filters, ServerlessOptions options) + { + string[] activityNames = ServerlessActivityConfiguration.ResolveActivityNames(options.ActivityNames); + if (activityNames.Length == 0) + { + return; + } + + filters.ExcludedActivities = MergeActivityFilters(filters.ExcludedActivities, activityNames); + } + + static void IncludeOnlyServerlessActivities(DurableTaskWorkerWorkItemFilters filters, ServerlessOptions options) + { + string[] activityNames = ServerlessActivityConfiguration.ResolveActivityNames(options.ActivityNames); + if (activityNames.Length == 0) + { + return; + } + + filters.Orchestrations = []; + filters.Activities = activityNames + .Select(static name => new DurableTaskWorkerWorkItemFilters.ActivityFilter { Name = name }) + .ToArray(); + filters.ExcludedActivities = []; + filters.Entities = []; + } + static ServerlessActivityDeclarationHostedService CreateServerlessActivityDeclarationHostedService( IServiceProvider services, string builderName) @@ -122,11 +167,10 @@ static void ApplyTaskHubDefault(ServerlessOptions options, string taskHubName) } } - static void ApplyServerlessEnvironmentOverrides(ServerlessOptions options) + static void ApplyWorkerEnvironmentOverrides(ServerlessOptions options) { // Auto-detect worker mode from DTS_SUBSTRATE, which the backend injects when - // launching a sandbox. This removes the need for callers to manually set Mode - // or inject DTS_SERVERLESS_MODE into the sandbox environment. + // launching a sandbox. This is the authoritative signal that this process is a sandbox worker. string? substrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); if (string.Equals(substrate, "Sandbox", StringComparison.OrdinalIgnoreCase) || string.Equals(substrate, "AcaSessionPool", StringComparison.OrdinalIgnoreCase)) @@ -134,27 +178,10 @@ static void ApplyServerlessEnvironmentOverrides(ServerlessOptions options) options.Mode = ServerlessMode.ServerlessInclude; } + // DTS_SERVERLESS_ACTIVITIES is injected by the coordinator into the sandbox environment. ApplyActivityNameEnvironmentOverride(options.ActivityNames); ApplyWorkerProfileEnvironmentOverride(profile => options.WorkerProfileId = profile); - string? image = Environment.GetEnvironmentVariable("DTS_SERVERLESS_ACTIVITY_IMAGE"); - if (!string.IsNullOrWhiteSpace(image)) - { - options.ContainerImage = image; - } - - string? cpu = Environment.GetEnvironmentVariable("DTS_SERVERLESS_CPU"); - if (!string.IsNullOrWhiteSpace(cpu)) - { - options.Cpu = cpu.Trim(); - } - - string? memory = Environment.GetEnvironmentVariable("DTS_SERVERLESS_MEMORY"); - if (!string.IsNullOrWhiteSpace(memory)) - { - options.Memory = memory.Trim(); - } - if (int.TryParse(Environment.GetEnvironmentVariable("DTS_SERVERLESS_MAX_ACTIVITIES"), out int maxActivities) && maxActivities > 0) { options.MaxConcurrentActivities = maxActivities; diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs index 512547ea..2b6cdef1 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs @@ -6,7 +6,7 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; /// /// Defines how a worker participates in serverless activity execution. /// -public enum ServerlessMode +internal enum ServerlessMode { /// /// The local worker declares serverless activities and excludes them from local execution. @@ -30,9 +30,9 @@ public sealed class ServerlessOptions internal const string DefaultWorkerProfileId = "default"; /// - /// Gets or sets the worker mode for serverless activity execution. + /// Gets the worker mode for serverless activity execution. Set automatically from the runtime environment. /// - public ServerlessMode Mode { get; set; } = ServerlessMode.LocalExclude; + internal ServerlessMode Mode { get; set; } = ServerlessMode.LocalExclude; /// /// Gets the serverless activity names to declare or execute. From a94c91c930e8289f5535abab189fef41db0bcabf Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 14 May 2026 23:40:31 -0700 Subject: [PATCH 06/81] logcleanup --- .../Worker/Serverless/Logs.cs | 56 ++++++++++++++ ...verlessActivityDeclarationHostedService.cs | 74 ++----------------- ...ActivityWorkerRegistrationHostedService.cs | 57 ++------------ 3 files changed, 66 insertions(+), 121 deletions(-) create mode 100644 src/Extensions/AzureManagedServerless/Worker/Serverless/Logs.cs diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/Logs.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/Logs.cs new file mode 100644 index 00000000..532247e3 --- /dev/null +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/Logs.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Proto = Microsoft.DurableTask.Protobuf.Serverless; + +namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; + +/// +/// Log messages for serverless activity services. +/// +static partial class Logs +{ + [LoggerMessage( + EventId = 1, + Level = LogLevel.Information, + Message = "No serverless activities discovered for hub={Hub}; skipping declaration")] + public static partial void NoServerlessActivitiesForDeclaration(ILogger logger, string hub); + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Information, + Message = "Serverless activities declared hub={Hub} workerProfile={WorkerProfile} count={Count} image={Image}")] + public static partial void ServerlessActivitiesDeclared(ILogger logger, string hub, string workerProfile, int count, string image); + + [LoggerMessage( + EventId = 3, + Level = LogLevel.Warning, + Message = "Serverless activity declaration failed transiently hub={Hub} attempt={Attempt} maxAttempts={MaxAttempts}")] + public static partial void ServerlessActivityDeclarationRetry(ILogger logger, Exception exception, string hub, int attempt, int maxAttempts); + + [LoggerMessage( + EventId = 4, + Level = LogLevel.Error, + Message = "Serverless activity declaration failed hub={Hub}")] + public static partial void ServerlessActivityDeclarationFailed(ILogger logger, Exception exception, string hub); + + [LoggerMessage( + EventId = 5, + Level = LogLevel.Information, + Message = "No serverless activities discovered for worker hub={Hub}; skipping live registration")] + public static partial void NoServerlessActivitiesForWorkerRegistration(ILogger logger, string hub); + + [LoggerMessage( + EventId = 6, + Level = LogLevel.Information, + Message = "Serverless activity worker registered hub={Hub} worker={Worker} count={Count} substrate={Substrate} sandboxId={SandboxId}")] + public static partial void ServerlessActivityWorkerRegistered( + ILogger logger, string hub, string worker, int count, Proto.SubstrateKind substrate, string sandboxId); + + [LoggerMessage( + EventId = 7, + Level = LogLevel.Error, + Message = "Serverless activity worker registration stream failed hub={Hub}")] + public static partial void ServerlessActivityWorkerRegistrationFailed(ILogger logger, Exception exception, string hub); +} diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs index eecf5fda..05ce1e58 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs @@ -11,7 +11,7 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; /// /// Hosted service that declares serverless activities with DTS when the local worker starts. /// -sealed partial class ServerlessActivityDeclarationHostedService : IHostedService +sealed class ServerlessActivityDeclarationHostedService : IHostedService { readonly IServerlessActivitiesClient client; readonly ServerlessOptions options; @@ -51,7 +51,7 @@ public async Task StartAsync(CancellationToken cancellationToken) string[] activityNames = ServerlessActivityConfiguration.ResolveActivityNames(this.options.ActivityNames); if (activityNames.Length == 0) { - Log.NoServerlessActivitiesDiscovered(this.logger, this.options.TaskHub); + Logs.NoServerlessActivitiesForDeclaration(this.logger, this.options.TaskHub); this.Ready.TrySetResult(null); return; } @@ -67,7 +67,7 @@ public async Task StartAsync(CancellationToken cancellationToken) this.options.TaskHub, cancellationToken).ConfigureAwait(false); this.Ready.TrySetResult(result); - Log.ServerlessActivitiesDeclared( + Logs.ServerlessActivitiesDeclared( this.logger, this.options.TaskHub, declaration.WorkerProfileId, @@ -77,7 +77,7 @@ public async Task StartAsync(CancellationToken cancellationToken) } catch (Exception ex) when (IsTransient(ex) && attempt < maxAttempts && !cancellationToken.IsCancellationRequested) { - Log.ServerlessActivityDeclarationRetry(this.logger, ex, this.options.TaskHub, attempt, maxAttempts); + Logs.ServerlessActivityDeclarationRetry(this.logger, ex, this.options.TaskHub, attempt, maxAttempts); if (this.options.DeclarationRetryDelay > TimeSpan.Zero) { await Task.Delay(this.options.DeclarationRetryDelay, cancellationToken).ConfigureAwait(false); @@ -86,7 +86,7 @@ public async Task StartAsync(CancellationToken cancellationToken) catch (Exception ex) { this.Ready.TrySetException(ex); - Log.ServerlessActivityDeclarationFailed(this.logger, ex, this.options.TaskHub); + Logs.ServerlessActivityDeclarationFailed(this.logger, ex, this.options.TaskHub); throw; } } @@ -101,68 +101,4 @@ exception is RpcException rpcException || rpcException.StatusCode == StatusCode.DeadlineExceeded || rpcException.StatusCode == StatusCode.ResourceExhausted || rpcException.StatusCode == StatusCode.Internal); - - static partial class Log - { - /// - /// Logs that no serverless activities were discovered for declaration. - /// - /// The logger. - /// The task hub name. - [LoggerMessage( - EventId = 1, - Level = LogLevel.Information, - Message = "No serverless activities discovered for hub={Hub}; skipping declaration")] - public static partial void NoServerlessActivitiesDiscovered(ILogger logger, string hub); - - /// - /// Logs a successful serverless activity declaration. - /// - /// The logger. - /// The task hub name. - /// The worker profile ID. - /// The declared activity count. - /// The serverless worker image reference. - [LoggerMessage( - EventId = 2, - Level = LogLevel.Information, - Message = "Serverless activities declared hub={Hub} workerProfile={WorkerProfile} count={Count} image={Image}")] - public static partial void ServerlessActivitiesDeclared( - ILogger logger, - string hub, - string workerProfile, - int count, - string image); - - /// - /// Logs a transient serverless activity declaration failure that will be retried. - /// - /// The logger. - /// The transient exception. - /// The task hub name. - /// The completed attempt count. - /// The maximum attempt count. - [LoggerMessage( - EventId = 3, - Level = LogLevel.Warning, - Message = "Serverless activity declaration failed transiently hub={Hub} attempt={Attempt} maxAttempts={MaxAttempts}")] - public static partial void ServerlessActivityDeclarationRetry( - ILogger logger, - Exception exception, - string hub, - int attempt, - int maxAttempts); - - /// - /// Logs a failed serverless activity declaration. - /// - /// The logger. - /// The declaration exception. - /// The task hub name. - [LoggerMessage( - EventId = 4, - Level = LogLevel.Error, - Message = "Serverless activity declaration failed hub={Hub}")] - public static partial void ServerlessActivityDeclarationFailed(ILogger logger, Exception exception, string hub); - } } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs index eb9b8a95..914b3097 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs @@ -11,7 +11,7 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; /// /// Hosted service that registers a running process as a serverless activity worker with DTS. /// -sealed partial class ServerlessActivityWorkerRegistrationHostedService : IHostedService, IAsyncDisposable +sealed class ServerlessActivityWorkerRegistrationHostedService : IHostedService, IAsyncDisposable { readonly IServerlessActivitiesClient client; readonly ServerlessOptions options; @@ -58,7 +58,7 @@ public async Task StartAsync(CancellationToken cancellationToken) string[] activityNames = ServerlessActivityConfiguration.ResolveActivityNames(this.options.ActivityNames); if (activityNames.Length == 0) { - Log.NoServerlessActivitiesDiscovered(this.logger, this.options.TaskHub); + Logs.NoServerlessActivitiesForWorkerRegistration(this.logger, this.options.TaskHub); this.Ready.TrySetResult(true); this.pump = Task.CompletedTask; return; @@ -74,7 +74,7 @@ public async Task StartAsync(CancellationToken cancellationToken) { await registrationSession.WriteMessageAsync(startMessage).ConfigureAwait(false); this.Ready.TrySetResult(true); - Log.ServerlessActivityWorkerRegistered( + Logs.ServerlessActivityWorkerRegistered( this.logger, startMessage.Start.TaskHub, startMessage.Start.WorkerInstanceId, @@ -85,7 +85,7 @@ public async Task StartAsync(CancellationToken cancellationToken) catch (Exception ex) { this.Ready.TrySetException(ex); - Log.ServerlessActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); + Logs.ServerlessActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); throw; } @@ -172,54 +172,7 @@ await registrationSession.WriteMessageAsync( void HandleRegistrationStreamFailure(Exception exception) { - Log.ServerlessActivityWorkerRegistrationFailed(this.logger, exception, this.options.TaskHub); + Logs.ServerlessActivityWorkerRegistrationFailed(this.logger, exception, this.options.TaskHub); this.lifetime?.StopApplication(); } - - static partial class Log - { - /// - /// Logs that no serverless activities were discovered for live worker registration. - /// - /// The logger. - /// The task hub name. - [LoggerMessage( - EventId = 1, - Level = LogLevel.Information, - Message = "No serverless activities discovered for worker hub={Hub}; skipping live registration")] - public static partial void NoServerlessActivitiesDiscovered(ILogger logger, string hub); - - /// - /// Logs a successful serverless activity worker registration. - /// - /// The logger. - /// The task hub name. - /// The worker instance ID. - /// The activity count. - /// The substrate kind. - /// The sandbox ID. - [LoggerMessage( - EventId = 2, - Level = LogLevel.Information, - Message = "Serverless activity worker registered hub={Hub} worker={Worker} count={Count} substrate={Substrate} sandboxId={SandboxId}")] - public static partial void ServerlessActivityWorkerRegistered( - ILogger logger, - string hub, - string worker, - int count, - Proto.SubstrateKind substrate, - string sandboxId); - - /// - /// Logs a failed serverless activity worker registration stream. - /// - /// The logger. - /// The registration exception. - /// The task hub name. - [LoggerMessage( - EventId = 3, - Level = LogLevel.Error, - Message = "Serverless activity worker registration stream failed hub={Hub}")] - public static partial void ServerlessActivityWorkerRegistrationFailed(ILogger logger, Exception exception, string hub); - } } From a1a03d89dc7fe51382b6b49bec9b7d4f136b0ac9 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 14 May 2026 23:50:31 -0700 Subject: [PATCH 07/81] use grpc retry --- .../Worker/Serverless/Logs.cs | 6 -- ...verlessActivityDeclarationHostedService.cs | 67 ++++++------------- ...ActivityWorkerRegistrationHostedService.cs | 9 --- .../Worker/Serverless/ServerlessOptions.cs | 18 ++--- src/Grpc/serverless_activities_service.proto | 3 - .../ServerlessActivitiesTests.cs | 36 +++++----- 6 files changed, 40 insertions(+), 99 deletions(-) diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/Logs.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/Logs.cs index 532247e3..dd6729d7 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/Logs.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/Logs.cs @@ -23,12 +23,6 @@ static partial class Logs Message = "Serverless activities declared hub={Hub} workerProfile={WorkerProfile} count={Count} image={Image}")] public static partial void ServerlessActivitiesDeclared(ILogger logger, string hub, string workerProfile, int count, string image); - [LoggerMessage( - EventId = 3, - Level = LogLevel.Warning, - Message = "Serverless activity declaration failed transiently hub={Hub} attempt={Attempt} maxAttempts={MaxAttempts}")] - public static partial void ServerlessActivityDeclarationRetry(ILogger logger, Exception exception, string hub, int attempt, int maxAttempts); - [LoggerMessage( EventId = 4, Level = LogLevel.Error, diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs index 05ce1e58..8c793388 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Grpc.Core; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Proto = Microsoft.DurableTask.Protobuf.Serverless; @@ -33,18 +32,11 @@ public ServerlessActivityDeclarationHostedService( this.logger = Check.NotNull(logger); } - /// - /// Gets a task completed when the declaration attempt succeeds, is skipped, or fails. - /// - internal TaskCompletionSource Ready { get; } = - new(TaskCreationOptions.RunContinuationsAsynchronously); - /// public async Task StartAsync(CancellationToken cancellationToken) { if (this.options.Mode == ServerlessMode.ServerlessInclude) { - this.Ready.TrySetResult(null); return; } @@ -52,53 +44,32 @@ public async Task StartAsync(CancellationToken cancellationToken) if (activityNames.Length == 0) { Logs.NoServerlessActivitiesForDeclaration(this.logger, this.options.TaskHub); - this.Ready.TrySetResult(null); return; } - Proto.ServerlessActivityDeclaration declaration = ServerlessActivityConfiguration.BuildDeclaration(this.options, activityNames); - int maxAttempts = Math.Max(1, this.options.DeclarationRetryMaxAttempts); - for (int attempt = 1; ; attempt++) + Proto.ServerlessActivityDeclaration declaration = ServerlessActivityConfiguration.BuildDeclaration( + this.options, + activityNames); + try { - try - { - Proto.ServerlessActivityDeclarationResult result = await this.client.DeclareServerlessActivitiesAsync( - declaration, - this.options.TaskHub, - cancellationToken).ConfigureAwait(false); - this.Ready.TrySetResult(result); - Logs.ServerlessActivitiesDeclared( - this.logger, - this.options.TaskHub, - declaration.WorkerProfileId, - declaration.ActivityNames.Count, - declaration.Image?.ImageRef ?? string.Empty); - return; - } - catch (Exception ex) when (IsTransient(ex) && attempt < maxAttempts && !cancellationToken.IsCancellationRequested) - { - Logs.ServerlessActivityDeclarationRetry(this.logger, ex, this.options.TaskHub, attempt, maxAttempts); - if (this.options.DeclarationRetryDelay > TimeSpan.Zero) - { - await Task.Delay(this.options.DeclarationRetryDelay, cancellationToken).ConfigureAwait(false); - } - } - catch (Exception ex) - { - this.Ready.TrySetException(ex); - Logs.ServerlessActivityDeclarationFailed(this.logger, ex, this.options.TaskHub); - throw; - } + await this.client.DeclareServerlessActivitiesAsync( + declaration, + this.options.TaskHub, + cancellationToken).ConfigureAwait(false); + Logs.ServerlessActivitiesDeclared( + this.logger, + this.options.TaskHub, + declaration.WorkerProfileId, + declaration.ActivityNames.Count, + declaration.Image?.ImageRef ?? string.Empty); + } + catch (Exception ex) + { + Logs.ServerlessActivityDeclarationFailed(this.logger, ex, this.options.TaskHub); + throw; } } /// public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - static bool IsTransient(Exception exception) => - exception is RpcException rpcException - && (rpcException.StatusCode == StatusCode.Unavailable - || rpcException.StatusCode == StatusCode.DeadlineExceeded - || rpcException.StatusCode == StatusCode.ResourceExhausted - || rpcException.StatusCode == StatusCode.Internal); } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs index 914b3097..60cd08b9 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs @@ -40,17 +40,11 @@ public ServerlessActivityWorkerRegistrationHostedService( this.lifetime = lifetime; } - /// - /// Gets a task completed when the worker registration succeeds, is skipped, or fails. - /// - internal TaskCompletionSource Ready { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - /// public async Task StartAsync(CancellationToken cancellationToken) { if (this.options.Mode != ServerlessMode.ServerlessInclude) { - this.Ready.TrySetResult(true); this.pump = Task.CompletedTask; return; } @@ -59,7 +53,6 @@ public async Task StartAsync(CancellationToken cancellationToken) if (activityNames.Length == 0) { Logs.NoServerlessActivitiesForWorkerRegistration(this.logger, this.options.TaskHub); - this.Ready.TrySetResult(true); this.pump = Task.CompletedTask; return; } @@ -73,7 +66,6 @@ public async Task StartAsync(CancellationToken cancellationToken) try { await registrationSession.WriteMessageAsync(startMessage).ConfigureAwait(false); - this.Ready.TrySetResult(true); Logs.ServerlessActivityWorkerRegistered( this.logger, startMessage.Start.TaskHub, @@ -84,7 +76,6 @@ public async Task StartAsync(CancellationToken cancellationToken) } catch (Exception ex) { - this.Ready.TrySetException(ex); Logs.ServerlessActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); throw; } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs index 2b6cdef1..161413bb 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs @@ -29,11 +29,6 @@ public sealed class ServerlessOptions /// internal const string DefaultWorkerProfileId = "default"; - /// - /// Gets the worker mode for serverless activity execution. Set automatically from the runtime environment. - /// - internal ServerlessMode Mode { get; set; } = ServerlessMode.LocalExclude; - /// /// Gets the serverless activity names to declare or execute. /// @@ -115,17 +110,12 @@ public sealed class ServerlessOptions public int MaxConcurrentActivities { get; set; } = 100; /// - /// Gets or sets the maximum number of declaration attempts made on transient failures. - /// - public int DeclarationRetryMaxAttempts { get; set; } = 5; - - /// - /// Gets or sets the delay between declaration retry attempts. + /// Gets or sets the interval used to refresh live worker capacity while the registration stream is open. /// - public TimeSpan DeclarationRetryDelay { get; set; } = TimeSpan.FromSeconds(1); + public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(2); /// - /// Gets or sets the interval used to refresh live worker capacity while the registration stream is open. + /// Gets or sets the worker mode for serverless activity execution. Set automatically from the runtime environment. /// - public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(2); + internal ServerlessMode Mode { get; set; } = ServerlessMode.LocalExclude; } diff --git a/src/Grpc/serverless_activities_service.proto b/src/Grpc/serverless_activities_service.proto index dba51559..f77c27be 100644 --- a/src/Grpc/serverless_activities_service.proto +++ b/src/Grpc/serverless_activities_service.proto @@ -51,9 +51,6 @@ message ServerlessActivityWorkerSessionResult { } message ServerlessActivityDeclaration { - reserved 1; - reserved 10; - reserved "launch_command"; string worker_profile_id = 2; repeated string activity_names = 3; ServerlessActivityImage image = 4; diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index 3f022fa4..9d2d15b4 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using Grpc.Core; using Microsoft.DurableTask.Protobuf.Serverless; +using Microsoft.DurableTask.Worker.AzureManaged; using Microsoft.DurableTask.Worker.AzureManaged.Serverless; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -19,9 +20,11 @@ public class ServerlessActivitiesTests const string TaskHub = "testhub"; [Fact] - public void ServerlessDeclarationContract_DoesNotExposeLaunchCommand() + public void ServerlessDeclarationContract_DoesNotExposeRemovedOptions() { typeof(ServerlessOptions).GetProperty("LaunchCommand").Should().BeNull(); + typeof(ServerlessOptions).GetProperty("DeclarationRetryMaxAttempts").Should().BeNull(); + typeof(ServerlessOptions).GetProperty("DeclarationRetryDelay").Should().BeNull(); typeof(ServerlessActivityDeclaration).GetProperty("LaunchCommand").Should().BeNull(); } @@ -116,15 +119,13 @@ public async Task ServerlessActivityDeclarationHostedService_SkipsDeclarationWhe } [Fact] - public async Task ServerlessActivityDeclarationHostedService_RetriesTransientFailures() + public async Task ServerlessActivityDeclarationHostedService_DoesNotRetryTransientFailures() { // Arrange ServerlessOptions options = new() { TaskHub = TaskHub, ContainerImage = "example.com/repo/worker@sha256:abc", - DeclarationRetryMaxAttempts = 2, - DeclarationRetryDelay = TimeSpan.Zero, }; options.ActivityNames.Add("RemoteHello"); FakeServerlessActivitiesClient client = new() { TransientDeclarationFailures = 1 }; @@ -134,11 +135,13 @@ public async Task ServerlessActivityDeclarationHostedService_RetriesTransientFai NullLogger.Instance); // Act - await service.StartAsync(CancellationToken.None); + Func action = () => service.StartAsync(CancellationToken.None); // Assert - client.DeclarationAttempts.Should().Be(2); - client.Declarations.Should().ContainSingle(); + await action.Should().ThrowAsync() + .Where(exception => exception.StatusCode == StatusCode.Unavailable); + client.DeclarationAttempts.Should().Be(1); + client.Declarations.Should().BeEmpty(); } [Fact] @@ -213,7 +216,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWor } [Fact] - public async Task UseServerlessActivities_LocalExclude_ConfiguresLocalWorkerExclusionFilter() + public async Task DeclareServerlessActivities_ConfiguresLocalWorkerExclusionFilter() { // Arrange using EnvironmentVariableScope serverlessActivities = new("DTS_SERVERLESS_ACTIVITIES", null); @@ -223,7 +226,7 @@ public async Task UseServerlessActivities_LocalExclude_ConfiguresLocalWorkerExcl mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - mockBuilder.Object.UseServerlessActivities(options => + mockBuilder.Object.DeclareServerlessActivities(options => { options.TaskHub = TaskHub; options.ContainerImage = "example.com/repo/worker:latest"; @@ -239,7 +242,7 @@ public async Task UseServerlessActivities_LocalExclude_ConfiguresLocalWorkerExcl } [Fact] - public async Task UseServerlessActivities_DoesNotConfigureFilterWhenActivityNamesAreEmpty() + public async Task DeclareServerlessActivities_DoesNotConfigureFilterWhenActivityNamesAreEmpty() { // Arrange using EnvironmentVariableScope serverlessActivities = new("DTS_SERVERLESS_ACTIVITIES", null); @@ -249,7 +252,7 @@ public async Task UseServerlessActivities_DoesNotConfigureFilterWhenActivityName mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - mockBuilder.Object.UseServerlessActivities(options => + mockBuilder.Object.DeclareServerlessActivities(options => { options.TaskHub = TaskHub; options.ContainerImage = "example.com/repo/worker:latest"; @@ -264,22 +267,17 @@ public async Task UseServerlessActivities_DoesNotConfigureFilterWhenActivityName } [Fact] - public async Task UseServerlessActivities_ServerlessInclude_ConfiguresServerlessActivityWorkerFilter() + public async Task UseServerlessWorker_ConfiguresServerlessActivityWorkerFilter() { // Arrange - using EnvironmentVariableScope serverlessActivities = new("DTS_SERVERLESS_ACTIVITIES", null); + using EnvironmentVariableScope serverlessActivities = new("DTS_SERVERLESS_ACTIVITIES", "RemoteHello"); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - mockBuilder.Object.UseServerlessActivities(options => - { - options.Mode = ServerlessMode.ServerlessInclude; - options.TaskHub = TaskHub; - options.ActivityNames.Add("RemoteHello"); - }); + mockBuilder.Object.UseServerlessWorker(); await using ServiceProvider provider = services.BuildServiceProvider(); DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); From f58cd4d0caab3ef6691569af764f5213600f87a2 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 15 May 2026 13:33:07 -0700 Subject: [PATCH 08/81] inflight activity tracker --- .../AzureManagedServerless.csproj | 1 + ...TaskSchedulerServerlessWorkerExtensions.cs | 30 +++- .../Serverless/ServerlessActivityTracker.cs | 42 +++++ ...ActivityWorkerRegistrationHostedService.cs | 9 +- .../Worker/Serverless/ServerlessOptions.cs | 5 + .../Serverless/ServerlessWakeupServer.cs | 102 ++++++++++++ .../Grpc/GrpcDurableTaskWorker.Processor.cs | 21 ++- .../Grpc/GrpcDurableTaskWorkerOptions.cs | 6 + .../Internal/InternalOptionsExtensions.cs | 34 ++++ .../ServerlessActivitiesTests.cs | 154 +++++++++++++++++- .../Grpc.Tests/GrpcDurableTaskWorkerTests.cs | 61 +++++++ 11 files changed, 456 insertions(+), 9 deletions(-) create mode 100644 src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityTracker.cs create mode 100644 src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWakeupServer.cs diff --git a/src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj b/src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj index a8e8fed6..578fe888 100644 --- a/src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj +++ b/src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs index 4b562311..3cd3f098 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -84,7 +84,23 @@ public static IDurableTaskWorkerBuilder UseServerlessWorker(this IDurableTaskWor .PostConfigure>( (filters, serverlessOptions) => IncludeOnlyServerlessActivities(filters, serverlessOptions.Get(builder.Name))); + builder.Services.AddSingleton(); + builder.Services.AddOptions(builder.Name) + .Configure((options, activityTracker) => + options.ConfigureActivityNotification(phase => + { + if (phase == ActivityNotificationPhase.Started) + { + activityTracker.NotifyActivityStarted(); + } + else if (phase == ActivityNotificationPhase.Completed) + { + activityTracker.NotifyActivityCompleted(); + } + })); + builder.Services.AddSingleton(sp => CreateServerlessActivityWorkerRegistrationHostedService(sp, builder.Name)); + builder.Services.AddSingleton(sp => CreateServerlessWakeupServer(sp, builder.Name)); return builder; } @@ -135,12 +151,24 @@ static ServerlessActivityWorkerRegistrationHostedService CreateServerlessActivit ServerlessOptions options = services.GetRequiredService>().Get(builderName); ILoggerFactory loggerFactory = services.GetRequiredService(); IHostApplicationLifetime? lifetime = services.GetService(); + ServerlessActivityTracker activityTracker = services.GetRequiredService(); return new ServerlessActivityWorkerRegistrationHostedService( CreateServerlessActivitiesClient(services, builderName), options, loggerFactory.CreateLogger(), - lifetime); + lifetime, + activityTracker); + } + + static ServerlessWakeupServer CreateServerlessWakeupServer(IServiceProvider services, string builderName) + { + ServerlessOptions options = services.GetRequiredService>().Get(builderName); + ILoggerFactory loggerFactory = services.GetRequiredService(); + + return new ServerlessWakeupServer( + options, + loggerFactory.CreateLogger()); } static ServerlessActivitiesClientAdapter CreateServerlessActivitiesClient(IServiceProvider services, string builderName) diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityTracker.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityTracker.cs new file mode 100644 index 00000000..36237ce2 --- /dev/null +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityTracker.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; + +/// +/// Tracks activity execution state for a serverless worker process. +/// +sealed class ServerlessActivityTracker +{ + int activeActivityCount; + + /// + /// Gets the number of activities currently in flight on this worker. + /// + public int InFlightCount => Volatile.Read(ref this.activeActivityCount); + + /// + /// Records the start of an in-flight activity. + /// + internal void NotifyActivityStarted() => Interlocked.Increment(ref this.activeActivityCount); + + /// + /// Records the completion of an activity. + /// + internal void NotifyActivityCompleted() + { + while (true) + { + int currentCount = Volatile.Read(ref this.activeActivityCount); + if (currentCount == 0) + { + return; + } + + if (Interlocked.CompareExchange(ref this.activeActivityCount, currentCount - 1, currentCount) == currentCount) + { + return; + } + } + } +} diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs index 60cd08b9..77df6a59 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs @@ -17,6 +17,7 @@ sealed class ServerlessActivityWorkerRegistrationHostedService : IHostedService, readonly ServerlessOptions options; readonly ILogger logger; readonly IHostApplicationLifetime? lifetime; + readonly ServerlessActivityTracker? activityTracker; CancellationTokenSource? cts; IServerlessActivityWorkerSession? session; Task? pump; @@ -28,16 +29,19 @@ sealed class ServerlessActivityWorkerRegistrationHostedService : IHostedService, /// The serverless options. /// The logger. /// The optional application lifetime used to stop the host when the registration stream fails. + /// The optional activity tracker used to report live in-flight activity count. public ServerlessActivityWorkerRegistrationHostedService( IServerlessActivitiesClient client, ServerlessOptions options, ILogger logger, - IHostApplicationLifetime? lifetime = null) + IHostApplicationLifetime? lifetime = null, + ServerlessActivityTracker? activityTracker = null) { this.client = Check.NotNull(client); this.options = Check.NotNull(options); this.logger = Check.NotNull(logger); this.lifetime = lifetime; + this.activityTracker = activityTracker; } /// @@ -148,8 +152,9 @@ async Task PumpHeartbeatsAsync( using PeriodicTimer timer = new(this.options.HeartbeatInterval); while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) { + int activeActivitiesCount = this.activityTracker?.InFlightCount ?? 0; await registrationSession.WriteMessageAsync( - ServerlessActivityConfiguration.BuildWorkerHeartbeat(activeActivitiesCount: 0)).ConfigureAwait(false); + ServerlessActivityConfiguration.BuildWorkerHeartbeat(activeActivitiesCount)).ConfigureAwait(false); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs index 161413bb..306bd3f6 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs @@ -114,6 +114,11 @@ public sealed class ServerlessOptions /// public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(2); + /// + /// Gets or sets the private HTTP port used by ADC to wake or probe a serverless worker container. + /// + public int WakeupPort { get; set; } = 8080; + /// /// Gets or sets the worker mode for serverless activity execution. Set automatically from the runtime environment. /// diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWakeupServer.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWakeupServer.cs new file mode 100644 index 00000000..2250f344 --- /dev/null +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWakeupServer.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; + +/// +/// Hosts a private HTTP listener that wakes or probes a serverless worker container. +/// +public sealed partial class ServerlessWakeupServer : IHostedService, IAsyncDisposable +{ + readonly ServerlessOptions options; + readonly ILogger logger; + WebApplication? app; + + /// + /// Initializes a new instance of the class. + /// + /// The serverless options. + /// The logger. + public ServerlessWakeupServer(ServerlessOptions options, ILogger logger) + { + this.options = Check.NotNull(options); + this.logger = Check.NotNull(logger); + } + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + if (this.options.Mode != ServerlessMode.ServerlessInclude || this.app is not null) + { + return; + } + + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(options => options.ListenAnyIP(this.options.WakeupPort)); + builder.Logging.ClearProviders(); + + WebApplication localApp = builder.Build(); + localApp.MapPost("/", static () => Results.Ok()); + localApp.MapPost("/wakeup", static () => Results.Ok()); + localApp.MapGet("/health", static () => Results.Ok()); + + try + { + await localApp.StartAsync(cancellationToken).ConfigureAwait(false); + this.app = localApp; + } + catch + { + await localApp.DisposeAsync().ConfigureAwait(false); + throw; + } + + Log.Started(this.logger, this.options.WakeupPort); + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + WebApplication? localApp = this.app; + this.app = null; + + if (localApp is null) + { + return; + } + + try + { + await localApp.StopAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + await localApp.DisposeAsync().ConfigureAwait(false); + Log.Stopped(this.logger, this.options.WakeupPort); + } + } + + /// + public ValueTask DisposeAsync() => new(this.StopAsync(CancellationToken.None)); + + static partial class Log + { + [LoggerMessage( + EventId = 1, + Level = LogLevel.Information, + Message = "Serverless wakeup server listening on port {Port}")] + public static partial void Started(ILogger logger, int port); + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Information, + Message = "Serverless wakeup server stopped on port {Port}")] + public static partial void Stopped(ILogger logger, int port); + } +} diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs index 4c5a18b2..40a2240b 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs @@ -156,7 +156,7 @@ await this.ProcessWorkItemsAsync( this.internalOptions.ReconnectBackoffBase, this.internalOptions.ReconnectBackoffCap, backoffRandom, - fullJitter: true); + fullJitter: true); this.Logger.ReconnectBackoff(reconnectAttempt, (int)delay.TotalMilliseconds); reconnectAttempt++; await Task.Delay(delay, cancellation); @@ -405,12 +405,23 @@ void DispatchWorkItem(P.WorkItem workItem, CancellationToken cancellation) } else if (workItem.RequestCase == P.WorkItem.RequestOneofCase.ActivityRequest) { + this.internalOptions.NotifyActivity?.Invoke(ActivityNotificationPhase.Started); this.RunBackgroundTask( workItem, - () => this.OnRunActivityAsync( - workItem.ActivityRequest, - workItem.CompletionToken, - cancellation), + async () => + { + try + { + await this.OnRunActivityAsync( + workItem.ActivityRequest, + workItem.CompletionToken, + cancellation).ConfigureAwait(false); + } + finally + { + this.internalOptions.NotifyActivity?.Invoke(ActivityNotificationPhase.Completed); + } + }, cancellation); } else if (workItem.RequestCase == P.WorkItem.RequestOneofCase.EntityRequest) diff --git a/src/Worker/Grpc/GrpcDurableTaskWorkerOptions.cs b/src/Worker/Grpc/GrpcDurableTaskWorkerOptions.cs index 49bd6350..59c21a00 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorkerOptions.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorkerOptions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.DurableTask.Worker.Grpc.Internal; using P = Microsoft.DurableTask.Protobuf; namespace Microsoft.DurableTask.Worker.Grpc; @@ -165,5 +166,10 @@ internal class InternalOptions /// deferring disposal of the old channel so in-flight RPCs already using it are not interrupted. /// public Func>? ChannelRecreator { get; set; } + + /// + /// Gets or sets a callback that is invoked when activity work items are received or finished. + /// + public Action? NotifyActivity { get; set; } } } diff --git a/src/Worker/Grpc/Internal/InternalOptionsExtensions.cs b/src/Worker/Grpc/Internal/InternalOptionsExtensions.cs index b26b36cc..764db9f7 100644 --- a/src/Worker/Grpc/Internal/InternalOptionsExtensions.cs +++ b/src/Worker/Grpc/Internal/InternalOptionsExtensions.cs @@ -7,6 +7,22 @@ namespace Microsoft.DurableTask.Worker.Grpc.Internal; +/// +/// Identifies the phase of activity execution being reported to internal worker hooks. +/// +public enum ActivityNotificationPhase +{ + /// + /// The worker has received and started processing an activity work item. + /// + Started, + + /// + /// The worker has finished processing an activity work item. + /// + Completed, +} + /// /// Provides access to configuring internal options for the gRPC worker. /// @@ -28,6 +44,24 @@ public static void ConfigureForAzureManaged(this GrpcDurableTaskWorkerOptions op options.Internal.InsertEntityUnlocksOnCompletion = true; } + /// + /// Registers a callback invoked when activity work items start and finish execution. + /// + /// The gRPC worker options. + /// The activity notification callback. + /// + /// This is an internal API that supports the DurableTask infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new DurableTask release. + /// + public static void ConfigureActivityNotification( + this GrpcDurableTaskWorkerOptions options, + Action notification) + { + options.Internal.NotifyActivity += notification ?? throw new ArgumentNullException(nameof(notification)); + } + /// /// Sets a callback that the worker invokes when the underlying gRPC channel needs to be recreated /// after repeated connect failures (e.g., because the backend was replaced and the existing channel diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index 9d2d15b4..d3de7b54 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Net; +using System.Net.Sockets; using FluentAssertions; using Grpc.Core; using Microsoft.DurableTask.Protobuf.Serverless; @@ -215,6 +217,71 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWor } } + [Fact] + public void ServerlessActivityTracker_TracksInFlightActivityCount() + { + // Arrange + ServerlessActivityTracker activityTracker = new(); + + // Act + activityTracker.NotifyActivityStarted(); + activityTracker.NotifyActivityStarted(); + + // Assert + activityTracker.InFlightCount.Should().Be(2); + + // Act + activityTracker.NotifyActivityCompleted(); + + // Assert + activityTracker.InFlightCount.Should().Be(1); + + // Act + activityTracker.NotifyActivityCompleted(); + activityTracker.NotifyActivityCompleted(); + + // Assert + activityTracker.InFlightCount.Should().Be(0); + } + + [Fact] + public async Task ServerlessActivityWorkerRegistrationHostedService_SendsHeartbeatWithCurrentInFlightCount() + { + // Arrange + ServerlessOptions options = new() + { + Mode = ServerlessMode.ServerlessInclude, + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + HeartbeatInterval = TimeSpan.FromMilliseconds(10), + }; + options.ActivityNames.Add("RemoteHello"); + + FakeServerlessActivitiesClient client = new(); + ServerlessActivityTracker activityTracker = new(); + activityTracker.NotifyActivityStarted(); + activityTracker.NotifyActivityStarted(); + + ServerlessActivityWorkerRegistrationHostedService service = new( + client, + options, + NullLogger.Instance, + lifetime: null, + activityTracker); + + // Act + await service.StartAsync(CancellationToken.None); + await client.Session.WaitForMessageAsync(message => message.Heartbeat?.ActiveActivitiesCount == 2); + activityTracker.NotifyActivityCompleted(); + await client.Session.WaitForMessageAsync(message => message.Heartbeat?.ActiveActivitiesCount == 1); + await service.StopAsync(CancellationToken.None); + + // Assert + client.Session.Messages.Should().Contain(message => message.Heartbeat != null && message.Heartbeat.ActiveActivitiesCount == 2); + client.Session.Messages.Should().Contain(message => message.Heartbeat != null && message.Heartbeat.ActiveActivitiesCount == 1); + } + [Fact] public async Task DeclareServerlessActivities_ConfiguresLocalWorkerExclusionFilter() { @@ -289,6 +356,59 @@ public async Task UseServerlessWorker_ConfiguresServerlessActivityWorkerFilter() filters.Entities.Should().BeEmpty(); } + [Fact] + public void UseServerlessWorker_RegistersWakeupServerHostedService() + { + // Arrange + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + // Act + mockBuilder.Object.UseServerlessWorker(); + + // Assert + services.Count(descriptor => descriptor.ServiceType == typeof(IHostedService)).Should().Be(2); + } + + [Fact] + public async Task ServerlessWakeupServer_RespondsToAdcProbesWhenWorkerIsServerless() + { + // Arrange + int wakeupPort = GetFreeTcpPort(); + ServerlessOptions options = new() + { + Mode = ServerlessMode.ServerlessInclude, + WakeupPort = wakeupPort, + }; + ServerlessWakeupServer server = new( + options, + NullLogger.Instance); + + // Act + await server.StartAsync(CancellationToken.None); + + try + { + using HttpClient httpClient = new(); + + // Assert + using HttpResponseMessage healthResponse = await httpClient.GetAsync( + $"http://127.0.0.1:{wakeupPort}/health"); + healthResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + using HttpResponseMessage wakeupResponse = await httpClient.PostAsync( + $"http://127.0.0.1:{wakeupPort}/wakeup", + new ByteArrayContent([])); + wakeupResponse.StatusCode.Should().Be(HttpStatusCode.OK); + } + finally + { + await server.StopAsync(CancellationToken.None); + } + } + sealed class FakeServerlessActivitiesClient : IServerlessActivitiesClient { public int TransientDeclarationFailures { get; init; } @@ -328,11 +448,36 @@ public IServerlessActivityWorkerSession OpenServerlessActivityWorkerSession(stri sealed class FakeServerlessActivityWorkerSession : IServerlessActivityWorkerSession { + readonly object sync = new(); + public List Messages { get; } = []; + public async Task WaitForMessageAsync(Func predicate) + { + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5)); + while (!timeout.IsCancellationRequested) + { + lock (this.sync) + { + if (this.Messages.Any(predicate)) + { + return; + } + } + + await Task.Delay(TimeSpan.FromMilliseconds(10), timeout.Token); + } + + throw new TimeoutException("Timed out waiting for serverless worker message."); + } + public Task WriteMessageAsync(ServerlessActivityWorkerMessage message) { - this.Messages.Add(message.Clone()); + lock (this.sync) + { + this.Messages.Add(message.Clone()); + } + return Task.CompletedTask; } @@ -355,4 +500,11 @@ public EnvironmentVariableScope(string name, string? value) public void Dispose() => Environment.SetEnvironmentVariable(this.name, this.originalValue); } + + static int GetFreeTcpPort() + { + using TcpListener listener = new(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } } \ No newline at end of file diff --git a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs index db6c98da..0fe26c55 100644 --- a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs +++ b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Concurrent; using System.IO; using System.Reflection; using Google.Protobuf.WellKnownTypes; @@ -28,6 +29,9 @@ public class GrpcDurableTaskWorkerTests static readonly MethodInfo ProcessorConnectAsyncMethod = typeof(GrpcDurableTaskWorker) .GetNestedType("Processor", BindingFlags.NonPublic)! .GetMethod("ConnectAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; + static readonly MethodInfo DispatchWorkItemMethod = typeof(GrpcDurableTaskWorker) + .GetNestedType("Processor", BindingFlags.NonPublic)! + .GetMethod("DispatchWorkItem", BindingFlags.Instance | BindingFlags.NonPublic)!; static readonly MethodInfo TryRecreateChannelAsyncMethod = typeof(GrpcDurableTaskWorker) .GetMethod("TryRecreateChannelAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; @@ -250,6 +254,58 @@ public async Task ProcessorExecuteAsync_GracefulDrainAfterFirstMessage_Reconnect logs.Should().NotContain(log => log.Message.Contains("Recreating gRPC channel to backend")); } + [Fact] + public async Task DispatchWorkItem_ActivityRequest_NotifiesActivityStartAndCompletion() + { + // Arrange + ConcurrentQueue notifications = new(); + TaskCompletionSource completed = new(TaskCreationOptions.RunContinuationsAsynchronously); + GrpcDurableTaskWorkerOptions grpcOptions = new(); + grpcOptions.ConfigureActivityNotification(phase => + { + notifications.Enqueue(phase); + if (phase == ActivityNotificationPhase.Completed) + { + completed.TrySetResult(); + } + }); + + P.WorkItem activityWorkItem = new() + { + ActivityRequest = new P.ActivityRequest + { + Name = "MyActivity", + TaskId = 42, + OrchestrationInstance = new P.OrchestrationInstance + { + InstanceId = "instance1", + ExecutionId = "execution1", + }, + }, + CompletionToken = "completion1", + }; + + GrpcDurableTaskWorker worker = CreateWorker(grpcOptions); + Mock clientMock = new( + MockBehavior.Strict, + new object[] { Mock.Of() }); + clientMock + .Setup(client => client.CompleteActivityTaskAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(CreateUnaryCall(Task.FromResult(new P.CompleteTaskResponse()))); + object processor = CreateProcessor(worker, clientMock.Object); + + // Act + InvokeDispatchWorkItem(processor, activityWorkItem, CancellationToken.None); + await completed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Assert + notifications.Should().Equal(ActivityNotificationPhase.Started, ActivityNotificationPhase.Completed); + } + [Fact] public async Task ProcessorExecuteAsync_HelloDeadlineExceeded_ReturnsChannelRecreateRequested() { @@ -542,6 +598,11 @@ static async Task InvokeProcessorExecuteAsync(object proces return (ProcessorExitReason)task.GetType().GetProperty("Result")!.GetValue(task)!; } + static void InvokeDispatchWorkItem(object processor, P.WorkItem workItem, CancellationToken cancellationToken) + { + DispatchWorkItemMethod.Invoke(processor, new object?[] { workItem, cancellationToken }); + } + static void InvokeApplySuccessfulRecreate( GrpcDurableTaskWorker worker, object result, From 48f72847a488c6b575992c5a446c4f9476b019d5 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 15 May 2026 14:03:25 -0700 Subject: [PATCH 09/81] serverless connection resilient --- ...ActivityWorkerRegistrationHostedService.cs | 204 +++++++++++++----- .../Worker/Serverless/ServerlessOptions.cs | 10 + .../ServerlessActivitiesTests.cs | 66 +++++- 3 files changed, 222 insertions(+), 58 deletions(-) diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs index 77df6a59..2152a8ee 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.IO; using Grpc.Core; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -13,6 +14,7 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; /// sealed class ServerlessActivityWorkerRegistrationHostedService : IHostedService, IAsyncDisposable { + readonly object sync = new(); readonly IServerlessActivitiesClient client; readonly ServerlessOptions options; readonly ILogger logger; @@ -28,7 +30,7 @@ sealed class ServerlessActivityWorkerRegistrationHostedService : IHostedService, /// The serverless activities client. /// The serverless options. /// The logger. - /// The optional application lifetime used to stop the host when the registration stream fails. + /// The optional application lifetime used to stop the host when a non-retriable registration stream failure occurs. /// The optional activity tracker used to report live in-flight activity count. public ServerlessActivityWorkerRegistrationHostedService( IServerlessActivitiesClient client, @@ -45,12 +47,12 @@ public ServerlessActivityWorkerRegistrationHostedService( } /// - public async Task StartAsync(CancellationToken cancellationToken) + public Task StartAsync(CancellationToken cancellationToken) { if (this.options.Mode != ServerlessMode.ServerlessInclude) { this.pump = Task.CompletedTask; - return; + return Task.CompletedTask; } string[] activityNames = ServerlessActivityConfiguration.ResolveActivityNames(this.options.ActivityNames); @@ -58,42 +60,35 @@ public async Task StartAsync(CancellationToken cancellationToken) { Logs.NoServerlessActivitiesForWorkerRegistration(this.logger, this.options.TaskHub); this.pump = Task.CompletedTask; - return; + return Task.CompletedTask; } CancellationTokenSource registrationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - this.cts = registrationCts; - IServerlessActivityWorkerSession registrationSession = this.client.OpenServerlessActivityWorkerSession(this.options.TaskHub, registrationCts.Token); - this.session = registrationSession; - - Proto.ServerlessActivityWorkerMessage startMessage = ServerlessActivityConfiguration.BuildWorkerStart(this.options); - try - { - await registrationSession.WriteMessageAsync(startMessage).ConfigureAwait(false); - Logs.ServerlessActivityWorkerRegistered( - this.logger, - startMessage.Start.TaskHub, - startMessage.Start.WorkerInstanceId, - activityNames.Length, - startMessage.Start.Substrate, - startMessage.Start.SandboxId); - } - catch (Exception ex) + Task registrationPump = Task.Run( + () => this.RunRegistrationLoopAsync(activityNames.Length, registrationCts.Token), + CancellationToken.None); + lock (this.sync) { - Logs.ServerlessActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); - throw; + this.cts = registrationCts; + this.pump = registrationPump; } - this.pump = Task.Run( - () => this.PumpHeartbeatsAsync(registrationSession, registrationCts.Token), - CancellationToken.None); + return Task.CompletedTask; } /// public async Task StopAsync(CancellationToken cancellationToken) { - CancellationTokenSource? localCts = this.cts; - IServerlessActivityWorkerSession? localSession = this.session; + CancellationTokenSource? localCts; + IServerlessActivityWorkerSession? localSession; + Task? localPump; + lock (this.sync) + { + localCts = this.cts; + localSession = this.session; + localPump = this.pump; + } + localCts?.Cancel(); if (localSession is not null) @@ -107,11 +102,11 @@ public async Task StopAsync(CancellationToken cancellationToken) } } - if (this.pump is not null) + if (localPump is not null) { try { - await this.pump.WaitAsync(cancellationToken).ConfigureAwait(false); + await localPump.WaitAsync(cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -121,54 +116,155 @@ public async Task StopAsync(CancellationToken cancellationToken) } } - if (localSession is not null) + lock (this.sync) { - await localSession.DisposeAsync().ConfigureAwait(false); - } + if (ReferenceEquals(this.cts, localCts)) + { + this.cts = null; + } - localCts?.Dispose(); - if (ReferenceEquals(this.cts, localCts)) - { - this.cts = null; - } + if (ReferenceEquals(this.session, localSession)) + { + this.session = null; + } - if (ReferenceEquals(this.session, localSession)) - { - this.session = null; + if (ReferenceEquals(this.pump, localPump)) + { + this.pump = Task.CompletedTask; + } } - this.pump = Task.CompletedTask; + localCts?.Dispose(); } /// public ValueTask DisposeAsync() => new(this.StopAsync(CancellationToken.None)); + async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancellationToken) + { + TimeSpan retryDelay = this.GetInitialRetryDelay(); + while (!cancellationToken.IsCancellationRequested) + { + IServerlessActivityWorkerSession? registrationSession = null; + try + { + registrationSession = this.client.OpenServerlessActivityWorkerSession(this.options.TaskHub, cancellationToken); + this.SetCurrentSession(registrationSession); + + Proto.ServerlessActivityWorkerMessage startMessage = ServerlessActivityConfiguration.BuildWorkerStart(this.options); + await registrationSession.WriteMessageAsync(startMessage).ConfigureAwait(false); + Logs.ServerlessActivityWorkerRegistered( + this.logger, + startMessage.Start.TaskHub, + startMessage.Start.WorkerInstanceId, + activityCount, + startMessage.Start.Substrate, + startMessage.Start.SandboxId); + + retryDelay = this.GetInitialRetryDelay(); + await this.PumpHeartbeatsAsync(registrationSession, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) when (!IsRetriableRegistrationFailure(ex)) + { + Logs.ServerlessActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); + this.lifetime?.StopApplication(); + break; + } + catch (Exception ex) + { + Logs.ServerlessActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); + await DelayBeforeReconnectAsync(retryDelay, cancellationToken).ConfigureAwait(false); + retryDelay = this.GetNextRetryDelay(retryDelay); + } + finally + { + if (registrationSession is not null) + { + this.ClearCurrentSession(registrationSession); + await DisposeSessionAsync(registrationSession).ConfigureAwait(false); + } + } + } + } + async Task PumpHeartbeatsAsync( IServerlessActivityWorkerSession registrationSession, CancellationToken cancellationToken) { - try + using PeriodicTimer timer = new(this.options.HeartbeatInterval); + while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) { - using PeriodicTimer timer = new(this.options.HeartbeatInterval); - while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + int activeActivitiesCount = this.activityTracker?.InFlightCount ?? 0; + await registrationSession.WriteMessageAsync( + ServerlessActivityConfiguration.BuildWorkerHeartbeat(activeActivitiesCount)).ConfigureAwait(false); + } + } + + void SetCurrentSession(IServerlessActivityWorkerSession registrationSession) + { + lock (this.sync) + { + this.session = registrationSession; + } + } + + void ClearCurrentSession(IServerlessActivityWorkerSession registrationSession) + { + lock (this.sync) + { + if (ReferenceEquals(this.session, registrationSession)) { - int activeActivitiesCount = this.activityTracker?.InFlightCount ?? 0; - await registrationSession.WriteMessageAsync( - ServerlessActivityConfiguration.BuildWorkerHeartbeat(activeActivitiesCount)).ConfigureAwait(false); + this.session = null; } } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + } + + TimeSpan GetInitialRetryDelay() => + this.options.WorkerRegistrationRetryInitialDelay <= this.options.WorkerRegistrationRetryMaxDelay + ? this.options.WorkerRegistrationRetryInitialDelay + : this.options.WorkerRegistrationRetryMaxDelay; + + TimeSpan GetNextRetryDelay(TimeSpan retryDelay) + { + if (retryDelay <= TimeSpan.Zero) { + return retryDelay; } - catch (Exception ex) + + long nextTicks = Math.Min(retryDelay.Ticks * 2, this.options.WorkerRegistrationRetryMaxDelay.Ticks); + return TimeSpan.FromTicks(nextTicks); + } + + static async Task DelayBeforeReconnectAsync(TimeSpan retryDelay, CancellationToken cancellationToken) + { + if (retryDelay > TimeSpan.Zero) { - this.HandleRegistrationStreamFailure(ex); + await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false); } } - void HandleRegistrationStreamFailure(Exception exception) + static async ValueTask DisposeSessionAsync(IServerlessActivityWorkerSession registrationSession) { - Logs.ServerlessActivityWorkerRegistrationFailed(this.logger, exception, this.options.TaskHub); - this.lifetime?.StopApplication(); + try + { + await registrationSession.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or RpcException) + { + } } + + static bool IsRetriableRegistrationFailure(Exception exception) => + exception is OperationCanceledException or ObjectDisposedException or IOException + || exception is RpcException rpcException + && rpcException.StatusCode is StatusCode.Cancelled + or StatusCode.DeadlineExceeded + or StatusCode.Internal + or StatusCode.ResourceExhausted + or StatusCode.Unavailable + or StatusCode.Unknown; } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs index 306bd3f6..27a62a76 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs @@ -114,6 +114,16 @@ public sealed class ServerlessOptions /// public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(2); + /// + /// Gets or sets the initial delay before retrying a failed worker registration stream. + /// + internal TimeSpan WorkerRegistrationRetryInitialDelay { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Gets or sets the maximum delay before retrying a failed worker registration stream. + /// + internal TimeSpan WorkerRegistrationRetryMaxDelay { get; set; } = TimeSpan.FromSeconds(30); + /// /// Gets or sets the private HTTP port used by ADC to wake or probe a serverless worker container. /// diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index d3de7b54..dffc62c6 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -198,6 +198,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWor // Act await service.StartAsync(CancellationToken.None); + await client.Session.WaitForMessageAsync(message => message.Start != null); await service.StopAsync(CancellationToken.None); // Assert @@ -267,8 +268,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_SendsHeartbe client, options, NullLogger.Instance, - lifetime: null, - activityTracker); + activityTracker: activityTracker); // Act await service.StartAsync(CancellationToken.None); @@ -282,6 +282,45 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_SendsHeartbe client.Session.Messages.Should().Contain(message => message.Heartbeat != null && message.Heartbeat.ActiveActivitiesCount == 1); } + [Fact] + public async Task ServerlessActivityWorkerRegistrationHostedService_ReopensSessionAfterTransientStreamFailure() + { + // Arrange + ServerlessOptions options = new() + { + Mode = ServerlessMode.ServerlessInclude, + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + HeartbeatInterval = TimeSpan.FromMilliseconds(10), + WorkerRegistrationRetryInitialDelay = TimeSpan.FromMilliseconds(10), + WorkerRegistrationRetryMaxDelay = TimeSpan.FromMilliseconds(10), + }; + options.ActivityNames.Add("RemoteHello"); + + FakeServerlessActivityWorkerSession failedSession = new() { ThrowOnWriteAttempt = 2 }; + FakeServerlessActivityWorkerSession recoveredSession = new(); + FakeServerlessActivitiesClient client = new(); + client.QueueSession(failedSession); + client.QueueSession(recoveredSession); + + ServerlessActivityWorkerRegistrationHostedService service = new( + client, + options, + NullLogger.Instance); + + // Act + await service.StartAsync(CancellationToken.None); + await failedSession.WaitForMessageAsync(message => message.Start != null); + await recoveredSession.WaitForMessageAsync(message => message.Start != null); + await service.StopAsync(CancellationToken.None); + + // Assert + client.SessionTaskHubs.Should().Equal(TaskHub, TaskHub); + failedSession.Messages.Should().ContainSingle(message => message.Start != null); + recoveredSession.Messages.Should().ContainSingle(message => message.Start != null); + } + [Fact] public async Task DeclareServerlessActivities_ConfiguresLocalWorkerExclusionFilter() { @@ -411,6 +450,8 @@ public async Task ServerlessWakeupServer_RespondsToAdcProbesWhenWorkerIsServerle sealed class FakeServerlessActivitiesClient : IServerlessActivitiesClient { + readonly Queue queuedSessions = new(); + public int TransientDeclarationFailures { get; init; } public int DeclarationAttempts { get; private set; } @@ -421,8 +462,12 @@ sealed class FakeServerlessActivitiesClient : IServerlessActivitiesClient public List SessionTaskHubs { get; } = []; + public List Sessions { get; } = []; + public FakeServerlessActivityWorkerSession Session { get; } = new(); + public void QueueSession(FakeServerlessActivityWorkerSession session) => this.queuedSessions.Enqueue(session); + public Task DeclareServerlessActivitiesAsync( ServerlessActivityDeclaration declaration, string taskHub, @@ -442,16 +487,23 @@ public Task DeclareServerlessActivitiesAsyn public IServerlessActivityWorkerSession OpenServerlessActivityWorkerSession(string taskHub, CancellationToken cancellationToken) { this.SessionTaskHubs.Add(taskHub); - return this.Session; + FakeServerlessActivityWorkerSession session = this.queuedSessions.Count > 0 + ? this.queuedSessions.Dequeue() + : this.Session; + this.Sessions.Add(session); + return session; } } sealed class FakeServerlessActivityWorkerSession : IServerlessActivityWorkerSession { readonly object sync = new(); + int writeAttempts; public List Messages { get; } = []; + public int? ThrowOnWriteAttempt { get; init; } + public async Task WaitForMessageAsync(Func predicate) { using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5)); @@ -475,6 +527,12 @@ public Task WriteMessageAsync(ServerlessActivityWorkerMessage message) { lock (this.sync) { + this.writeAttempts++; + if (this.ThrowOnWriteAttempt == this.writeAttempts) + { + throw new RpcException(new Status(StatusCode.Unavailable, "transient")); + } + this.Messages.Add(message.Clone()); } @@ -507,4 +565,4 @@ static int GetFreeTcpPort() listener.Start(); return ((IPEndPoint)listener.LocalEndpoint).Port; } -} \ No newline at end of file +} From 7c94c399bbfc80df6007cf0ddc228ef8dad8304b Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 15 May 2026 15:27:12 -0700 Subject: [PATCH 10/81] completeasync --- .../ServerlessActivitiesClientAdapter.cs | 20 ++- ...ActivityWorkerRegistrationHostedService.cs | 76 ++++++++- .../ServerlessActivitiesTests.cs | 159 +++++++++++++++++- 3 files changed, 242 insertions(+), 13 deletions(-) diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs index 4ac4735e..dffa128e 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs @@ -45,9 +45,15 @@ interface IServerlessActivityWorkerSession : IAsyncDisposable Task WriteMessageAsync(Proto.ServerlessActivityWorkerMessage message); /// - /// Completes the request stream. + /// Waits for the server to complete the worker registration session. /// - /// A task that completes when the stream is completed. + /// The worker session result. + Task WaitForCompletionAsync(); + + /// + /// Completes the request stream and waits for the server response. + /// + /// A task that completes when the server response is observed. Task CompleteAsync(); } @@ -111,7 +117,15 @@ public Task WriteMessageAsync(Proto.ServerlessActivityWorkerMessage message) => this.call.RequestStream.WriteAsync(message); /// - public Task CompleteAsync() => this.call.RequestStream.CompleteAsync(); + public async Task WaitForCompletionAsync() => + await this.call.ResponseAsync.ConfigureAwait(false); + + /// + public async Task CompleteAsync() + { + await this.call.RequestStream.CompleteAsync().ConfigureAwait(false); + await this.WaitForCompletionAsync().ConfigureAwait(false); + } /// public ValueTask DisposeAsync() diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs index 2152a8ee..13c4cc49 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs @@ -20,6 +20,7 @@ sealed class ServerlessActivityWorkerRegistrationHostedService : IHostedService, readonly ILogger logger; readonly IHostApplicationLifetime? lifetime; readonly ServerlessActivityTracker? activityTracker; + readonly SemaphoreSlim streamSync = new(1, 1); CancellationTokenSource? cts; IServerlessActivityWorkerSession? session; Task? pump; @@ -95,7 +96,7 @@ public async Task StopAsync(CancellationToken cancellationToken) { try { - await localSession.CompleteAsync().ConfigureAwait(false); + await this.CompleteSessionAsync(localSession, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or RpcException) { @@ -152,7 +153,7 @@ async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancell this.SetCurrentSession(registrationSession); Proto.ServerlessActivityWorkerMessage startMessage = ServerlessActivityConfiguration.BuildWorkerStart(this.options); - await registrationSession.WriteMessageAsync(startMessage).ConfigureAwait(false); + await this.WriteSessionMessageAsync(registrationSession, startMessage, cancellationToken).ConfigureAwait(false); Logs.ServerlessActivityWorkerRegistered( this.logger, startMessage.Start.TaskHub, @@ -162,7 +163,7 @@ async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancell startMessage.Start.SandboxId); retryDelay = this.GetInitialRetryDelay(); - await this.PumpHeartbeatsAsync(registrationSession, cancellationToken).ConfigureAwait(false); + await this.RunRegistrationSessionAsync(registrationSession, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -191,6 +192,37 @@ async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancell } } + async Task RunRegistrationSessionAsync( + IServerlessActivityWorkerSession registrationSession, + CancellationToken cancellationToken) + { + using CancellationTokenSource heartbeatCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task heartbeatTask = this.PumpHeartbeatsAsync(registrationSession, heartbeatCts.Token); + Task completionTask = registrationSession.WaitForCompletionAsync(); + Task completedTask = await Task.WhenAny(heartbeatTask, completionTask).ConfigureAwait(false); + + if (ReferenceEquals(completedTask, completionTask)) + { + await heartbeatCts.CancelAsync().ConfigureAwait(false); + try + { + await heartbeatTask.ConfigureAwait(false); + } + catch (OperationCanceledException) when (heartbeatCts.IsCancellationRequested) + { + } + catch (Exception) + { + // The server response is authoritative once the response task wins the race. + } + + await completionTask.ConfigureAwait(false); + return; + } + + await heartbeatTask.ConfigureAwait(false); + } + async Task PumpHeartbeatsAsync( IServerlessActivityWorkerSession registrationSession, CancellationToken cancellationToken) @@ -199,8 +231,42 @@ async Task PumpHeartbeatsAsync( while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) { int activeActivitiesCount = this.activityTracker?.InFlightCount ?? 0; - await registrationSession.WriteMessageAsync( - ServerlessActivityConfiguration.BuildWorkerHeartbeat(activeActivitiesCount)).ConfigureAwait(false); + await this.WriteSessionMessageAsync( + registrationSession, + ServerlessActivityConfiguration.BuildWorkerHeartbeat(activeActivitiesCount), + cancellationToken).ConfigureAwait(false); + } + } + + async Task WriteSessionMessageAsync( + IServerlessActivityWorkerSession registrationSession, + Proto.ServerlessActivityWorkerMessage message, + CancellationToken cancellationToken) + { + await this.streamSync.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cancellationToken.ThrowIfCancellationRequested(); + await registrationSession.WriteMessageAsync(message).ConfigureAwait(false); + } + finally + { + this.streamSync.Release(); + } + } + + async Task CompleteSessionAsync( + IServerlessActivityWorkerSession registrationSession, + CancellationToken cancellationToken) + { + await this.streamSync.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await registrationSession.CompleteAsync().WaitAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + this.streamSync.Release(); } } diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index dffc62c6..2b61f473 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -321,6 +321,86 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_ReopensSessi recoveredSession.Messages.Should().ContainSingle(message => message.Start != null); } + [Fact] + public async Task ServerlessActivityWorkerRegistrationHostedService_ReopensSessionAfterTerminalServerFailure() + { + // Arrange + ServerlessOptions options = new() + { + Mode = ServerlessMode.ServerlessInclude, + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + HeartbeatInterval = TimeSpan.FromDays(1), + WorkerRegistrationRetryInitialDelay = TimeSpan.FromMilliseconds(10), + WorkerRegistrationRetryMaxDelay = TimeSpan.FromMilliseconds(10), + }; + options.ActivityNames.Add("RemoteHello"); + + FakeServerlessActivityWorkerSession failedSession = new(); + FakeServerlessActivityWorkerSession recoveredSession = new(); + FakeServerlessActivitiesClient client = new(); + client.QueueSession(failedSession); + client.QueueSession(recoveredSession); + + ServerlessActivityWorkerRegistrationHostedService service = new( + client, + options, + NullLogger.Instance); + + // Act + await service.StartAsync(CancellationToken.None); + await failedSession.WaitForMessageAsync(message => message.Start != null); + failedSession.FailCompletion(new RpcException(new Status(StatusCode.Unavailable, "terminal"))); + await recoveredSession.WaitForMessageAsync(message => message.Start != null); + await service.StopAsync(CancellationToken.None); + + // Assert + client.SessionTaskHubs.Should().Equal(TaskHub, TaskHub); + failedSession.Messages.Should().ContainSingle(message => message.Start != null); + recoveredSession.Messages.Should().ContainSingle(message => message.Start != null); + } + + [Fact] + public async Task ServerlessActivityWorkerRegistrationHostedService_StopAsync_DoesNotCompleteStreamWhileWriteIsInFlight() + { + // Arrange + ServerlessOptions options = new() + { + Mode = ServerlessMode.ServerlessInclude, + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + HeartbeatInterval = TimeSpan.FromMilliseconds(10), + }; + options.ActivityNames.Add("RemoteHello"); + + FakeServerlessActivityWorkerSession session = new() { BlockWriteAttempt = 2 }; + FakeServerlessActivitiesClient client = new(); + client.QueueSession(session); + + ServerlessActivityWorkerRegistrationHostedService service = new( + client, + options, + NullLogger.Instance); + + // Act + await service.StartAsync(CancellationToken.None); + await session.WaitForBlockedWriteAsync(); + Task stopTask = service.StopAsync(CancellationToken.None); + Task completeAttempt = session.WaitForCompleteAsync(); + Task completeBeforeWriteReleased = await Task.WhenAny( + completeAttempt, + Task.Delay(TimeSpan.FromMilliseconds(100))); + session.ReleaseBlockedWrite(); + await stopTask.WaitAsync(TimeSpan.FromSeconds(5)); + + // Assert + completeBeforeWriteReleased.Should().NotBe(completeAttempt); + session.CompleteCalled.Should().BeTrue(); + session.CompleteCalledWhileWriteActive.Should().BeFalse(); + } + [Fact] public async Task DeclareServerlessActivities_ConfiguresLocalWorkerExclusionFilter() { @@ -498,12 +578,39 @@ public IServerlessActivityWorkerSession OpenServerlessActivityWorkerSession(stri sealed class FakeServerlessActivityWorkerSession : IServerlessActivityWorkerSession { readonly object sync = new(); + readonly TaskCompletionSource completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + readonly TaskCompletionSource blockedWriteStarted = + new(TaskCreationOptions.RunContinuationsAsynchronously); + readonly TaskCompletionSource releaseBlockedWrite = + new(TaskCreationOptions.RunContinuationsAsynchronously); int writeAttempts; + int activeWrites; public List Messages { get; } = []; public int? ThrowOnWriteAttempt { get; init; } + public int? BlockWriteAttempt { get; init; } + + public bool CompleteCalled { get; private set; } + + public bool CompleteCalledWhileWriteActive { get; private set; } + + public void FailCompletion(Exception exception) => this.completion.TrySetException(exception); + + public Task WaitForBlockedWriteAsync() => this.blockedWriteStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + public Task WaitForCompleteAsync() + { + lock (this.sync) + { + return this.CompleteCalled ? Task.CompletedTask : this.completion.Task; + } + } + + public void ReleaseBlockedWrite() => this.releaseBlockedWrite.TrySetResult(); + public async Task WaitForMessageAsync(Func predicate) { using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5)); @@ -525,23 +632,65 @@ public async Task WaitForMessageAsync(Func Task.CompletedTask; + public Task WaitForCompletionAsync() => this.completion.Task; + + public async Task CompleteAsync() + { + lock (this.sync) + { + this.CompleteCalled = true; + this.CompleteCalledWhileWriteActive = this.activeWrites > 0; + } + + this.completion.TrySetResult(new ServerlessActivityWorkerSessionResult { Accepted = true }); + await this.completion.Task.ConfigureAwait(false); + } public ValueTask DisposeAsync() => default; + + async Task WriteMessageCoreAsync(ServerlessActivityWorkerMessage message, bool blockWrite) + { + try + { + if (blockWrite) + { + await this.releaseBlockedWrite.Task.ConfigureAwait(false); + } + + lock (this.sync) + { + this.Messages.Add(message.Clone()); + } + } + finally + { + lock (this.sync) + { + this.activeWrites--; + } + } + } } sealed class EnvironmentVariableScope : IDisposable From cfa428e797e0d840c7fda4b47631d76c87e15eae Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 15 May 2026 16:16:25 -0700 Subject: [PATCH 11/81] add jitter --- ...ActivityWorkerRegistrationHostedService.cs | 75 +++++++++++------- .../ServerlessActivitiesTests.cs | 78 +++++++++++++++++++ 2 files changed, 127 insertions(+), 26 deletions(-) diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs index 13c4cc49..f7226bea 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs @@ -20,6 +20,7 @@ sealed class ServerlessActivityWorkerRegistrationHostedService : IHostedService, readonly ILogger logger; readonly IHostApplicationLifetime? lifetime; readonly ServerlessActivityTracker? activityTracker; + readonly Random reconnectJitter; readonly SemaphoreSlim streamSync = new(1, 1); CancellationTokenSource? cts; IServerlessActivityWorkerSession? session; @@ -33,18 +34,21 @@ sealed class ServerlessActivityWorkerRegistrationHostedService : IHostedService, /// The logger. /// The optional application lifetime used to stop the host when a non-retriable registration stream failure occurs. /// The optional activity tracker used to report live in-flight activity count. + /// The optional random source used to jitter reconnect delays. public ServerlessActivityWorkerRegistrationHostedService( IServerlessActivitiesClient client, ServerlessOptions options, ILogger logger, IHostApplicationLifetime? lifetime = null, - ServerlessActivityTracker? activityTracker = null) + ServerlessActivityTracker? activityTracker = null, + Random? reconnectJitter = null) { this.client = Check.NotNull(client); this.options = Check.NotNull(options); this.logger = Check.NotNull(logger); this.lifetime = lifetime; this.activityTracker = activityTracker; + this.reconnectJitter = reconnectJitter ?? Random.Shared; } /// @@ -141,6 +145,45 @@ public async Task StopAsync(CancellationToken cancellationToken) /// public ValueTask DisposeAsync() => new(this.StopAsync(CancellationToken.None)); + /// + /// Computes a full-jitter reconnect delay in the range [0, retryDelay). + /// + /// The current exponential retry delay. + /// The random source used for jitter. + /// The jittered reconnect delay. + internal static TimeSpan ComputeJitteredReconnectDelay(TimeSpan retryDelay, Random random) + { + Check.NotNull(random); + if (retryDelay <= TimeSpan.Zero) + { + return TimeSpan.Zero; + } + + long jitteredTicks = (long)(random.NextDouble() * retryDelay.Ticks); + return TimeSpan.FromTicks(jitteredTicks); + } + + static async ValueTask DisposeSessionAsync(IServerlessActivityWorkerSession registrationSession) + { + try + { + await registrationSession.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or RpcException) + { + } + } + + static bool IsRetriableRegistrationFailure(Exception exception) => + (exception is OperationCanceledException or ObjectDisposedException or IOException) + || (exception is RpcException rpcException + && rpcException.StatusCode is StatusCode.Cancelled + or StatusCode.DeadlineExceeded + or StatusCode.Internal + or StatusCode.ResourceExhausted + or StatusCode.Unavailable + or StatusCode.Unknown); + async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancellationToken) { TimeSpan retryDelay = this.GetInitialRetryDelay(); @@ -178,7 +221,7 @@ async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancell catch (Exception ex) { Logs.ServerlessActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); - await DelayBeforeReconnectAsync(retryDelay, cancellationToken).ConfigureAwait(false); + await this.DelayBeforeReconnectAsync(retryDelay, cancellationToken).ConfigureAwait(false); retryDelay = this.GetNextRetryDelay(retryDelay); } finally @@ -305,32 +348,12 @@ TimeSpan GetNextRetryDelay(TimeSpan retryDelay) return TimeSpan.FromTicks(nextTicks); } - static async Task DelayBeforeReconnectAsync(TimeSpan retryDelay, CancellationToken cancellationToken) + async Task DelayBeforeReconnectAsync(TimeSpan retryDelay, CancellationToken cancellationToken) { - if (retryDelay > TimeSpan.Zero) - { - await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false); - } - } - - static async ValueTask DisposeSessionAsync(IServerlessActivityWorkerSession registrationSession) - { - try - { - await registrationSession.DisposeAsync().ConfigureAwait(false); - } - catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or RpcException) + TimeSpan jitteredDelay = ComputeJitteredReconnectDelay(retryDelay, this.reconnectJitter); + if (jitteredDelay > TimeSpan.Zero) { + await Task.Delay(jitteredDelay, cancellationToken).ConfigureAwait(false); } } - - static bool IsRetriableRegistrationFailure(Exception exception) => - exception is OperationCanceledException or ObjectDisposedException or IOException - || exception is RpcException rpcException - && rpcException.StatusCode is StatusCode.Cancelled - or StatusCode.DeadlineExceeded - or StatusCode.Internal - or StatusCode.ResourceExhausted - or StatusCode.Unavailable - or StatusCode.Unknown; } diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index 2b61f473..917af64c 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -361,6 +361,72 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_ReopensSessi recoveredSession.Messages.Should().ContainSingle(message => message.Start != null); } + [Fact] + public void ServerlessActivityWorkerRegistrationHostedService_ComputeJitteredReconnectDelay_UsesFullJitterWindow() + { + // Arrange + TimeSpan retryDelay = TimeSpan.FromSeconds(10); + + // Act + TimeSpan zero = ServerlessActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( + TimeSpan.Zero, + new DeterministicRandom(0.5)); + TimeSpan low = ServerlessActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( + retryDelay, + new DeterministicRandom(0.0)); + TimeSpan mid = ServerlessActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( + retryDelay, + new DeterministicRandom(0.5)); + TimeSpan high = ServerlessActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( + retryDelay, + new DeterministicRandom(0.999999)); + + // Assert + zero.Should().Be(TimeSpan.Zero); + low.Should().Be(TimeSpan.Zero); + mid.Should().Be(TimeSpan.FromSeconds(5)); + high.Should().BeGreaterThan(TimeSpan.FromSeconds(9)); + high.Should().BeLessThan(retryDelay); + } + + [Fact] + public async Task ServerlessActivityWorkerRegistrationHostedService_AppliesJitterToReconnectDelay() + { + // Arrange + ServerlessOptions options = new() + { + Mode = ServerlessMode.ServerlessInclude, + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + HeartbeatInterval = TimeSpan.FromMilliseconds(10), + WorkerRegistrationRetryInitialDelay = TimeSpan.FromDays(1), + WorkerRegistrationRetryMaxDelay = TimeSpan.FromDays(1), + }; + options.ActivityNames.Add("RemoteHello"); + + FakeServerlessActivityWorkerSession failedSession = new() { ThrowOnWriteAttempt = 2 }; + FakeServerlessActivityWorkerSession recoveredSession = new(); + FakeServerlessActivitiesClient client = new(); + client.QueueSession(failedSession); + client.QueueSession(recoveredSession); + + ServerlessActivityWorkerRegistrationHostedService service = new( + client, + options, + NullLogger.Instance, + reconnectJitter: new DeterministicRandom(0.0)); + + // Act + await service.StartAsync(CancellationToken.None); + await failedSession.WaitForMessageAsync(message => message.Start != null); + await recoveredSession.WaitForMessageAsync(message => message.Start != null); + await service.StopAsync(CancellationToken.None); + + // Assert + client.SessionTaskHubs.Should().Equal(TaskHub, TaskHub); + } + [Fact] public async Task ServerlessActivityWorkerRegistrationHostedService_StopAsync_DoesNotCompleteStreamWhileWriteIsInFlight() { @@ -693,6 +759,18 @@ async Task WriteMessageCoreAsync(ServerlessActivityWorkerMessage message, bool b } } + sealed class DeterministicRandom : Random + { + readonly double value; + + public DeterministicRandom(double value) + { + this.value = value; + } + + protected override double Sample() => this.value; + } + sealed class EnvironmentVariableScope : IDisposable { readonly string name; From 11a79140fe32bf8e597cd6191527a5855b03b787 Mon Sep 17 00:00:00 2001 From: wangbill Date: Mon, 18 May 2026 15:59:58 -0700 Subject: [PATCH 12/81] dup headers --- .../Worker/Serverless/ServerlessActivitiesClientAdapter.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs index dffa128e..bda90dbc 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs @@ -81,7 +81,6 @@ public ServerlessActivitiesClientAdapter(Proto.ServerlessActivities.ServerlessAc { return await this.client.DeclareServerlessActivitiesAsync( declaration, - headers: CreateTaskHubHeaders(taskHub), cancellationToken: cancellationToken) .ResponseAsync.ConfigureAwait(false); } @@ -90,12 +89,10 @@ public ServerlessActivitiesClientAdapter(Proto.ServerlessActivities.ServerlessAc public IServerlessActivityWorkerSession OpenServerlessActivityWorkerSession(string taskHub, CancellationToken cancellationToken) { AsyncClientStreamingCall call = - this.client.ConnectServerlessActivityWorker(headers: CreateTaskHubHeaders(taskHub), cancellationToken: cancellationToken); + this.client.ConnectServerlessActivityWorker(cancellationToken: cancellationToken); return new GrpcServerlessActivityWorkerSession(call); } - static Metadata CreateTaskHubHeaders(string taskHub) => new() { { "taskhub", taskHub } }; - /// /// gRPC-backed serverless activity worker registration session. /// From 821069a691df2974b2ac48adf568b834977e736d Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 19 May 2026 18:06:09 -0700 Subject: [PATCH 13/81] make taskhub metadata opt-out ServerlessActivitiesClientAdapter now takes an attachTaskHubMetadata flag (default true). The Azure-managed channel already injects the taskhub header via CallCredentials, so the AddDurableTaskScheduler* path constructs the adapter with attachTaskHubMetadata: false to avoid sending duplicate headers on DeclareServerlessActivities and ConnectServerlessActivityWorker. Added two unit tests with a recording CallInvoker covering both modes. --- ...TaskSchedulerServerlessWorkerExtensions.cs | 4 +- .../ServerlessActivitiesClientAdapter.cs | 16 +- .../ServerlessActivitiesTests.cs | 146 ++++++++++++++++++ 3 files changed, 163 insertions(+), 3 deletions(-) diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs index 3cd3f098..c98bd203 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -181,7 +181,9 @@ static ServerlessActivitiesClientAdapter CreateServerlessActivitiesClient(IServi if (options.Channel is { } channel) { - return new ServerlessActivitiesClientAdapter(new ServerlessActivities.ServerlessActivitiesClient(channel.CreateCallInvoker())); + return new ServerlessActivitiesClientAdapter( + new ServerlessActivities.ServerlessActivitiesClient(channel.CreateCallInvoker()), + attachTaskHubMetadata: false); } throw new InvalidOperationException("Azure Managed serverless activities require a configured gRPC channel or call invoker."); diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs index bda90dbc..2ad14739 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs @@ -63,14 +63,19 @@ interface IServerlessActivityWorkerSession : IAsyncDisposable sealed class ServerlessActivitiesClientAdapter : IServerlessActivitiesClient { readonly Proto.ServerlessActivities.ServerlessActivitiesClient client; + readonly bool attachTaskHubMetadata; /// /// Initializes a new instance of the class. /// /// The generated serverless activities gRPC client. - public ServerlessActivitiesClientAdapter(Proto.ServerlessActivities.ServerlessActivitiesClient client) + /// True to add per-call task hub metadata when the underlying channel does not already do so. + public ServerlessActivitiesClientAdapter( + Proto.ServerlessActivities.ServerlessActivitiesClient client, + bool attachTaskHubMetadata = true) { this.client = Check.NotNull(client); + this.attachTaskHubMetadata = attachTaskHubMetadata; } /// @@ -81,6 +86,7 @@ public ServerlessActivitiesClientAdapter(Proto.ServerlessActivities.ServerlessAc { return await this.client.DeclareServerlessActivitiesAsync( declaration, + headers: this.CreateTaskHubHeaders(taskHub), cancellationToken: cancellationToken) .ResponseAsync.ConfigureAwait(false); } @@ -89,10 +95,16 @@ public ServerlessActivitiesClientAdapter(Proto.ServerlessActivities.ServerlessAc public IServerlessActivityWorkerSession OpenServerlessActivityWorkerSession(string taskHub, CancellationToken cancellationToken) { AsyncClientStreamingCall call = - this.client.ConnectServerlessActivityWorker(cancellationToken: cancellationToken); + this.client.ConnectServerlessActivityWorker( + headers: this.CreateTaskHubHeaders(taskHub), + cancellationToken: cancellationToken); return new GrpcServerlessActivityWorkerSession(call); } + Metadata? CreateTaskHubHeaders(string taskHub) => this.attachTaskHubMetadata + ? new Metadata { { "taskhub", taskHub }, } + : null; + /// /// gRPC-backed serverless activity worker registration session. /// diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index 917af64c..53f1c71e 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -73,6 +73,76 @@ public async Task ServerlessActivityDeclarationHostedService_SendsDeclarationPay declaration.MaxConcurrentActivities.Should().Be(7); } + [Fact] + public async Task ServerlessActivitiesClientAdapter_SendsTaskHubMetadata() + { + // Arrange + RecordingServerlessActivitiesCallInvoker callInvoker = new(); + ServerlessActivitiesClientAdapter adapter = new(new ServerlessActivities.ServerlessActivitiesClient(callInvoker)); + ServerlessActivityDeclaration declaration = new() + { + WorkerProfileId = "profile-a", + Image = new ServerlessActivityImage + { + ImageRef = "example.com/repo/worker:latest", + PublicPull = true, + }, + Resources = new ServerlessActivityResources + { + Cpu = "500m", + Memory = "1024Mi", + }, + MaxConcurrentActivities = 7, + }; + declaration.ActivityNames.Add("RemoteHello"); + + // Act + await adapter.DeclareServerlessActivitiesAsync(declaration, TaskHub, CancellationToken.None); + await using IServerlessActivityWorkerSession session = adapter.OpenServerlessActivityWorkerSession( + TaskHub, + CancellationToken.None); + + // Assert + callInvoker.DeclarationHeaders.Should().Contain(header => header.Key == "taskhub" && header.Value == TaskHub); + callInvoker.WorkerSessionHeaders.Should().Contain(header => header.Key == "taskhub" && header.Value == TaskHub); + } + + [Fact] + public async Task ServerlessActivitiesClientAdapter_CanRelyOnChannelTaskHubMetadata() + { + // Arrange + RecordingServerlessActivitiesCallInvoker callInvoker = new(); + ServerlessActivitiesClientAdapter adapter = new( + new ServerlessActivities.ServerlessActivitiesClient(callInvoker), + attachTaskHubMetadata: false); + ServerlessActivityDeclaration declaration = new() + { + WorkerProfileId = "profile-a", + Image = new ServerlessActivityImage + { + ImageRef = "example.com/repo/worker:latest", + PublicPull = true, + }, + Resources = new ServerlessActivityResources + { + Cpu = "500m", + Memory = "1024Mi", + }, + MaxConcurrentActivities = 7, + }; + declaration.ActivityNames.Add("RemoteHello"); + + // Act + await adapter.DeclareServerlessActivitiesAsync(declaration, TaskHub, CancellationToken.None); + await using IServerlessActivityWorkerSession session = adapter.OpenServerlessActivityWorkerSession( + TaskHub, + CancellationToken.None); + + // Assert + callInvoker.DeclarationHeaders.Should().NotContain(header => header.Key == "taskhub"); + callInvoker.WorkerSessionHeaders.Should().NotContain(header => header.Key == "taskhub"); + } + [Fact] public async Task ServerlessActivityDeclarationHostedService_OmitsEntrypointAndCmdByDefault() { @@ -641,6 +711,82 @@ public IServerlessActivityWorkerSession OpenServerlessActivityWorkerSession(stri } } + sealed class RecordingServerlessActivitiesCallInvoker : CallInvoker + { + public Metadata DeclarationHeaders { get; private set; } = []; + + public Metadata WorkerSessionHeaders { get; private set; } = []; + + public override TResponse BlockingUnaryCall( + Method method, + string? host, + CallOptions options, + TRequest request) + { + throw new NotSupportedException(); + } + + public override AsyncUnaryCall AsyncUnaryCall( + Method method, + string? host, + CallOptions options, + TRequest request) + { + method.FullName.Should().EndWith("/DeclareServerlessActivities"); + this.DeclarationHeaders = options.Headers ?? []; + + return new AsyncUnaryCall( + Task.FromResult((TResponse)(object)new ServerlessActivityDeclarationResult()), + Task.FromResult(new Metadata()), + () => new Status(StatusCode.OK, string.Empty), + () => [], + () => { }); + } + + public override AsyncServerStreamingCall AsyncServerStreamingCall( + Method method, + string? host, + CallOptions options, + TRequest request) + { + throw new NotSupportedException(); + } + + public override AsyncClientStreamingCall AsyncClientStreamingCall( + Method method, + string? host, + CallOptions options) + { + method.FullName.Should().EndWith("/ConnectServerlessActivityWorker"); + this.WorkerSessionHeaders = options.Headers ?? []; + + return new AsyncClientStreamingCall( + new RecordingClientStreamWriter(), + Task.FromResult((TResponse)(object)new ServerlessActivityWorkerSessionResult()), + Task.FromResult(new Metadata()), + () => new Status(StatusCode.OK, string.Empty), + () => [], + () => { }); + } + + public override AsyncDuplexStreamingCall AsyncDuplexStreamingCall( + Method method, + string? host, + CallOptions options) + { + throw new NotSupportedException(); + } + } + + sealed class RecordingClientStreamWriter : IClientStreamWriter + { + public WriteOptions? WriteOptions { get; set; } + + public Task WriteAsync(T message) => Task.CompletedTask; + + public Task CompleteAsync() => Task.CompletedTask; + } + sealed class FakeServerlessActivityWorkerSession : IServerlessActivityWorkerSession { readonly object sync = new(); From 1077961e1d3a6e9c36f36a1fe2259b6f435eb477 Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 19 May 2026 21:32:23 -0700 Subject: [PATCH 14/81] Add serverless sandbox list and log APIs --- .../ServerlessActivitiesClientExtensions.cs | 100 +++++++++++------- .../Client/ServerlessSandboxInfo.cs | 17 +++ .../Client/ServerlessSandboxLogLine.cs | 4 +- .../Worker/Serverless/Logs.cs | 4 +- .../ServerlessActivityConfiguration.cs | 3 +- ...ActivityWorkerRegistrationHostedService.cs | 3 +- .../Worker/Serverless/ServerlessOptions.cs | 5 - src/Grpc/serverless_activities_service.proto | 45 ++++++-- ...rverlessActivitiesClientExtensionsTests.cs | 76 +++++++++++-- .../ServerlessActivitiesTests.cs | 2 +- 10 files changed, 193 insertions(+), 66 deletions(-) create mode 100644 src/Extensions/AzureManagedServerless/Client/ServerlessSandboxInfo.cs diff --git a/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs b/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs index 96940740..4a7f3a8c 100644 --- a/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs +++ b/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs @@ -18,76 +18,90 @@ public static class ServerlessActivitiesClientExtensions const int MaxTail = 300; /// - /// Streams logs from a serverless activity sandbox using task hub metadata already configured on the gRPC channel. + /// Lists DTS-managed sandboxes for a serverless activity worker profile using task hub metadata already configured on the gRPC channel. /// /// The generated serverless activities gRPC client. - /// The sandbox ID to stream logs from. - /// The number of historical log lines to include before streaming live logs. Must be between 0 and 300. - /// The cancellation token used to stop streaming. - /// An async stream of sandbox log lines. - public static IAsyncEnumerable StreamSandboxLogsAsync( + /// The worker profile ID to list sandboxes for. + /// The cancellation token used to cancel the request. + /// The sandboxes currently known to DTS for the worker profile. + public static Task> ListServerlessActivitySandboxesAsync( this Proto.ServerlessActivities.ServerlessActivitiesClient client, - string sandboxId, - int tail = 100, + string workerProfileId, CancellationToken cancellation = default) { - return StreamSandboxLogsCoreAsync( + return ListServerlessActivitySandboxesCoreAsync( client, - sandboxId, - taskHub: null, - tail, + workerProfileId, cancellation); } /// - /// Streams logs from a serverless activity sandbox with explicit task hub metadata. + /// Streams logs from a serverless activity sandbox using task hub metadata already configured on the gRPC channel. /// /// The generated serverless activities gRPC client. - /// The sandbox ID to stream logs from. - /// The task hub that owns the sandbox. + /// The DTS sandbox identifier to stream logs from. /// The number of historical log lines to include before streaming live logs. Must be between 0 and 300. /// The cancellation token used to stop streaming. /// An async stream of sandbox log lines. public static IAsyncEnumerable StreamSandboxLogsAsync( this Proto.ServerlessActivities.ServerlessActivitiesClient client, - string sandboxId, - string taskHub, + string dtsSandboxIdentifier, int tail = 100, CancellationToken cancellation = default) { - if (string.IsNullOrWhiteSpace(taskHub)) - { - throw new ArgumentException("Task hub name is required.", nameof(taskHub)); - } - return StreamSandboxLogsCoreAsync( client, - sandboxId, - taskHub, + dtsSandboxIdentifier, tail, cancellation); } + static async Task> ListServerlessActivitySandboxesCoreAsync( + Proto.ServerlessActivities.ServerlessActivitiesClient client, + string workerProfileId, + CancellationToken cancellation) + { + ArgumentNullException.ThrowIfNull(client); + ValidateRequired(workerProfileId, nameof(workerProfileId), "Worker profile ID is required."); + + Proto.ListServerlessActivitySandboxesRequest request = new() + { + WorkerProfileId = workerProfileId, + }; + + using AsyncUnaryCall call = client.ListServerlessActivitySandboxesAsync( + request, + headers: null, + cancellationToken: cancellation); + Proto.ListServerlessActivitySandboxesResult result = await call.ResponseAsync.ConfigureAwait(false); + + List sandboxes = new(result.Sandboxes.Count); + foreach (Proto.ServerlessActivitySandbox sandbox in result.Sandboxes) + { + sandboxes.Add(FromProto(sandbox)); + } + + return sandboxes; + } + static async IAsyncEnumerable StreamSandboxLogsCoreAsync( Proto.ServerlessActivities.ServerlessActivitiesClient client, - string sandboxId, - string? taskHub, + string dtsSandboxIdentifier, int tail, [EnumeratorCancellation] CancellationToken cancellation) { ArgumentNullException.ThrowIfNull(client); - ValidateRequest(sandboxId, tail); + ValidateRequest(dtsSandboxIdentifier, tail); Proto.SandboxLogStreamRequest request = new() { - SandboxId = sandboxId, + DtsSandboxIdentifier = dtsSandboxIdentifier, Tail = tail, }; - Metadata? headers = taskHub is null ? null : new Metadata { { "taskhub", taskHub } }; using AsyncServerStreamingCall call = client.StreamSandboxLogs( request, - headers: headers, + headers: null, cancellationToken: cancellation); while (await call.ResponseStream.MoveNext(cancellation).ConfigureAwait(false)) @@ -96,12 +110,12 @@ static async IAsyncEnumerable StreamSandboxLogsCoreAsy } } - static void ValidateRequest(string sandboxId, int tail) + static void ValidateRequest(string dtsSandboxIdentifier, int tail) { - if (string.IsNullOrWhiteSpace(sandboxId)) - { - throw new ArgumentException("Sandbox ID is required.", nameof(sandboxId)); - } + ValidateRequired( + dtsSandboxIdentifier, + nameof(dtsSandboxIdentifier), + "DTS sandbox identifier is required."); if (tail < MinTail || tail > MaxTail) { @@ -112,8 +126,22 @@ static void ValidateRequest(string sandboxId, int tail) } } + static void ValidateRequired(string value, string parameterName, string message) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException(message, parameterName); + } + } + + static ServerlessSandboxInfo FromProto(Proto.ServerlessActivitySandbox sandbox) => new( + sandbox.DtsSandboxIdentifier, + sandbox.WorkerProfileId, + sandbox.CreatedAt?.ToDateTimeOffset() ?? default, + sandbox.State); + static ServerlessSandboxLogLine FromProto(Proto.SandboxLogLine line) => new( - line.SandboxId, + line.DtsSandboxIdentifier, line.Timestamp?.ToDateTimeOffset() ?? default, line.Stream, line.Tag, diff --git a/src/Extensions/AzureManagedServerless/Client/ServerlessSandboxInfo.cs b/src/Extensions/AzureManagedServerless/Client/ServerlessSandboxInfo.cs new file mode 100644 index 00000000..b9aa6cde --- /dev/null +++ b/src/Extensions/AzureManagedServerless/Client/ServerlessSandboxInfo.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Client.AzureManaged; + +/// +/// A DTS-managed sandbox that can execute serverless activities for a worker profile. +/// +/// The DTS-generated sandbox identifier injected into the worker as DTS_SANDBOX_ID. +/// The worker profile associated with the sandbox. +/// The time when the sandbox was created. +/// The current sandbox state reported by DTS. +public sealed record ServerlessSandboxInfo( + string DtsSandboxIdentifier, + string WorkerProfileId, + DateTimeOffset CreatedAt, + string State); diff --git a/src/Extensions/AzureManagedServerless/Client/ServerlessSandboxLogLine.cs b/src/Extensions/AzureManagedServerless/Client/ServerlessSandboxLogLine.cs index 06389a45..f7dbd7cb 100644 --- a/src/Extensions/AzureManagedServerless/Client/ServerlessSandboxLogLine.cs +++ b/src/Extensions/AzureManagedServerless/Client/ServerlessSandboxLogLine.cs @@ -6,14 +6,14 @@ namespace Microsoft.DurableTask.Client.AzureManaged; /// /// A log line emitted by a serverless activity sandbox. /// -/// The sandbox ID that produced the log line. +/// The DTS sandbox identifier that produced the log line. /// The timestamp associated with the log line. /// The output stream that produced the line, such as stdout or stderr. /// The log tag reported by the sandbox runtime. /// The parsed log message. /// The original log line. public sealed record ServerlessSandboxLogLine( - string SandboxId, + string DtsSandboxIdentifier, DateTimeOffset Timestamp, string Stream, string Tag, diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/Logs.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/Logs.cs index dd6729d7..3f1bbfa0 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/Logs.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/Logs.cs @@ -38,9 +38,9 @@ static partial class Logs [LoggerMessage( EventId = 6, Level = LogLevel.Information, - Message = "Serverless activity worker registered hub={Hub} worker={Worker} count={Count} substrate={Substrate} sandboxId={SandboxId}")] + Message = "Serverless activity worker registered hub={Hub} count={Count} substrate={Substrate} sandboxId={SandboxId}")] public static partial void ServerlessActivityWorkerRegistered( - ILogger logger, string hub, string worker, int count, Proto.SubstrateKind substrate, string sandboxId); + ILogger logger, string hub, int count, Proto.SubstrateKind substrate, string sandboxId); [LoggerMessage( EventId = 7, diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs index e278fcf5..85fc0645 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs @@ -86,10 +86,9 @@ public static Proto.ServerlessActivityWorkerMessage BuildWorkerStart(ServerlessO { TaskHub = options.TaskHub, WorkerProfileId = workerProfileId, - WorkerInstanceId = options.WorkerInstanceId, MaxActivitiesCount = options.MaxConcurrentActivities, Substrate = GetSubstrateFromEnvironment(), - SandboxId = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID") ?? string.Empty, + DtsSandboxIdentifier = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID") ?? string.Empty, }; return new Proto.ServerlessActivityWorkerMessage { Start = start }; diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs index f7226bea..a3b85841 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs @@ -200,10 +200,9 @@ async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancell Logs.ServerlessActivityWorkerRegistered( this.logger, startMessage.Start.TaskHub, - startMessage.Start.WorkerInstanceId, activityCount, startMessage.Start.Substrate, - startMessage.Start.SandboxId); + startMessage.Start.DtsSandboxIdentifier); retryDelay = this.GetInitialRetryDelay(); await this.RunRegistrationSessionAsync(registrationSession, cancellationToken).ConfigureAwait(false); diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs index 27a62a76..4619350f 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs @@ -99,11 +99,6 @@ public sealed class ServerlessOptions /// public IList Cmd { get; } = new List(); - /// - /// Gets the unique worker instance identifier. - /// - public string WorkerInstanceId { get; } = Guid.NewGuid().ToString("N"); - /// /// Gets or sets the maximum number of concurrent activities expected from each serverless worker. /// diff --git a/src/Grpc/serverless_activities_service.proto b/src/Grpc/serverless_activities_service.proto index f77c27be..a99e1d5e 100644 --- a/src/Grpc/serverless_activities_service.proto +++ b/src/Grpc/serverless_activities_service.proto @@ -19,7 +19,14 @@ service ServerlessActivities { // configuration contract and does not advertise active worker capacity. rpc DeclareServerlessActivities(ServerlessActivityDeclaration) returns (ServerlessActivityDeclarationResult); - // Streams best-effort stdout/stderr log lines from an ADC sandbox. + // Removes a serverless activity declaration so the backend stops waking new workers + // for the specified worker profile. Existing workers are not terminated by this RPC. + rpc RemoveServerlessActivityDeclaration(RemoveServerlessActivityDeclarationRequest) returns (RemoveServerlessActivityDeclarationResult); + + // Lists DTS-managed sandboxes for a declared worker profile in the current task hub. + rpc ListServerlessActivitySandboxes(ListServerlessActivitySandboxesRequest) returns (ListServerlessActivitySandboxesResult); + + // Streams best-effort stdout/stderr log lines from a DTS-managed sandbox. rpc StreamSandboxLogs(SandboxLogStreamRequest) returns (stream SandboxLogLine); } @@ -31,13 +38,16 @@ message ServerlessActivityWorkerMessage { } message ServerlessActivityWorkerStart { + reserved 2; + reserved "worker_instance_id"; + string task_hub = 1; - string worker_instance_id = 2; int32 max_activities_count = 3; // Substrate the worker is running in. UNSPECIFIED = legacy (pre-substrate-aware) workers. SubstrateKind substrate = 4; - // Identifier of the ADC sandbox the worker is running inside. Empty when substrate != SANDBOX. - string sandbox_id = 5; + // DTS-generated sandbox identifier injected as DTS_SANDBOX_ID. This is not + // the ADC provider sandbox resource id. + string dts_sandbox_identifier = 5; string worker_profile_id = 6; } @@ -74,13 +84,36 @@ message ServerlessActivityResources { message ServerlessActivityDeclarationResult { } +message RemoveServerlessActivityDeclarationRequest { + string worker_profile_id = 1; +} + +message RemoveServerlessActivityDeclarationResult { +} + +message ListServerlessActivitySandboxesRequest { + string worker_profile_id = 1; +} + +message ListServerlessActivitySandboxesResult { + repeated ServerlessActivitySandbox sandboxes = 1; +} + +message ServerlessActivitySandbox { + string dts_sandbox_identifier = 1; + string worker_profile_id = 2; + google.protobuf.Timestamp created_at = 3; + string state = 4; +} + message SandboxLogStreamRequest { - string sandbox_id = 1; + // DTS-generated sandbox identifier injected into the worker as DTS_SANDBOX_ID. + string dts_sandbox_identifier = 1; int32 tail = 2; } message SandboxLogLine { - string sandbox_id = 1; + string dts_sandbox_identifier = 1; google.protobuf.Timestamp timestamp = 2; string stream = 3; string tag = 4; diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs index 104cb30a..067bd496 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs @@ -11,7 +11,42 @@ namespace Microsoft.DurableTask.Client.AzureManaged.Tests; public class ServerlessActivitiesClientExtensionsTests { - const string TaskHub = "testhub"; + [Fact] + public async Task ListServerlessActivitySandboxesAsync_SendsRequestAndMapsSandboxes() + { + // Arrange + DateTimeOffset createdAt = new(2026, 5, 14, 10, 30, 0, TimeSpan.Zero); + RecordingServerlessLogCallInvoker callInvoker = new( + new ListServerlessActivitySandboxesResult + { + Sandboxes = + { + new ServerlessActivitySandbox + { + DtsSandboxIdentifier = "sandbox-1", + WorkerProfileId = "default", + CreatedAt = createdAt.ToTimestamp(), + State = "Running", + }, + }, + }); + ServerlessActivities.ServerlessActivitiesClient client = new(callInvoker); + + // Act + IReadOnlyList sandboxes = await client.ListServerlessActivitySandboxesAsync("default"); + + // Assert + callInvoker.ListRequest.Should().NotBeNull(); + callInvoker.ListRequest!.WorkerProfileId.Should().Be("default"); + callInvoker.ListHeaders.Should().NotContain(header => header.Key == "taskhub"); + callInvoker.UnaryDisposeCount.Should().Be(1); + + ServerlessSandboxInfo mapped = sandboxes.Should().ContainSingle().Subject; + mapped.DtsSandboxIdentifier.Should().Be("sandbox-1"); + mapped.WorkerProfileId.Should().Be("default"); + mapped.CreatedAt.Should().Be(createdAt); + mapped.State.Should().Be("Running"); + } [Fact] public async Task StreamSandboxLogsAsync_SendsRequestAndMapsLines() @@ -21,7 +56,7 @@ public async Task StreamSandboxLogsAsync_SendsRequestAndMapsLines() RecordingServerlessLogCallInvoker callInvoker = new( new SandboxLogLine { - SandboxId = "sandbox-1", + DtsSandboxIdentifier = "sandbox-1", Timestamp = timestamp.ToTimestamp(), Stream = "stdout", Tag = "worker", @@ -34,7 +69,6 @@ public async Task StreamSandboxLogsAsync_SendsRequestAndMapsLines() List lines = []; await foreach (ServerlessSandboxLogLine line in client.StreamSandboxLogsAsync( "sandbox-1", - TaskHub, tail: 42)) { lines.Add(line); @@ -42,13 +76,13 @@ public async Task StreamSandboxLogsAsync_SendsRequestAndMapsLines() // Assert callInvoker.Request.Should().NotBeNull(); - callInvoker.Request!.SandboxId.Should().Be("sandbox-1"); + callInvoker.Request!.DtsSandboxIdentifier.Should().Be("sandbox-1"); callInvoker.Request.Tail.Should().Be(42); - callInvoker.Headers.Should().Contain(header => header.Key == "taskhub" && header.Value == TaskHub); + callInvoker.Headers.Should().NotContain(header => header.Key == "taskhub"); callInvoker.DisposeCount.Should().Be(1); ServerlessSandboxLogLine mapped = lines.Should().ContainSingle().Subject; - mapped.SandboxId.Should().Be("sandbox-1"); + mapped.DtsSandboxIdentifier.Should().Be("sandbox-1"); mapped.Timestamp.Should().Be(timestamp); mapped.Stream.Should().Be("stdout"); mapped.Tag.Should().Be("worker"); @@ -57,7 +91,7 @@ public async Task StreamSandboxLogsAsync_SendsRequestAndMapsLines() } [Fact] - public async Task StreamSandboxLogsAsync_WithoutExplicitTaskHub_UsesConfiguredChannelMetadata() + public async Task StreamSandboxLogsAsync_DoesNotAttachTaskHubMetadata() { // Arrange RecordingServerlessLogCallInvoker callInvoker = new(); @@ -85,8 +119,7 @@ public async Task StreamSandboxLogsAsync_WithInvalidTail_ThrowsArgumentOutOfRang { await foreach (ServerlessSandboxLogLine _ in client.StreamSandboxLogsAsync( "sandbox-1", - TaskHub, - tail)) + tail: tail)) { } }; @@ -99,10 +132,18 @@ await action.Should().ThrowAsync() sealed class RecordingServerlessLogCallInvoker : CallInvoker { readonly SandboxLogStreamReader responseStream; + readonly ListServerlessActivitySandboxesResult listResponse; public RecordingServerlessLogCallInvoker(params SandboxLogLine[] lines) { this.responseStream = new SandboxLogStreamReader(lines); + this.listResponse = new ListServerlessActivitySandboxesResult(); + } + + public RecordingServerlessLogCallInvoker(ListServerlessActivitySandboxesResult listResponse) + { + this.responseStream = new SandboxLogStreamReader([]); + this.listResponse = listResponse; } public SandboxLogStreamRequest? Request { get; private set; } @@ -111,6 +152,12 @@ public RecordingServerlessLogCallInvoker(params SandboxLogLine[] lines) public int DisposeCount { get; private set; } + public ListServerlessActivitySandboxesRequest? ListRequest { get; private set; } + + public Metadata ListHeaders { get; private set; } = []; + + public int UnaryDisposeCount { get; private set; } + public override TResponse BlockingUnaryCall( Method method, string? host, @@ -126,7 +173,16 @@ public override AsyncUnaryCall AsyncUnaryCall( CallOptions options, TRequest request) { - throw new NotSupportedException(); + method.FullName.Should().EndWith("/ListServerlessActivitySandboxes"); + this.ListRequest = (ListServerlessActivitySandboxesRequest)(object)request; + this.ListHeaders = options.Headers ?? []; + + return new AsyncUnaryCall( + Task.FromResult((TResponse)(object)this.listResponse), + Task.FromResult(new Metadata()), + () => new Status(StatusCode.OK, string.Empty), + () => new Metadata(), + () => this.UnaryDisposeCount++); } public override AsyncServerStreamingCall AsyncServerStreamingCall( diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index 53f1c71e..e0da0b07 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -279,7 +279,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWor start.WorkerProfileId.Should().Be("profile-a"); start.MaxActivitiesCount.Should().Be(3); start.Substrate.Should().Be(SubstrateKind.Sandbox); - start.SandboxId.Should().Be("sandbox-1"); + start.DtsSandboxIdentifier.Should().Be("sandbox-1"); } finally { From e1433f2b8b10a8fbda93ec57e9ddea05a30e40b7 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 20 May 2026 11:56:57 -0700 Subject: [PATCH 15/81] remove declarat --- .../ServerlessActivitiesClientExtensions.cs | 38 +++++++++++++++++ ...rverlessActivitiesClientExtensionsTests.cs | 42 +++++++++++++++++-- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs b/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs index 4a7f3a8c..892b1985 100644 --- a/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs +++ b/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs @@ -35,6 +35,24 @@ public static Task> ListServerlessActivityS cancellation); } + /// + /// Removes a serverless activity declaration for a worker profile using task hub metadata already configured on the gRPC channel. + /// + /// The generated serverless activities gRPC client. + /// The worker profile ID whose declaration should be removed. + /// The cancellation token used to cancel the request. + /// A task that completes when DTS removes the declaration. + public static Task RemoveServerlessActivityDeclarationAsync( + this Proto.ServerlessActivities.ServerlessActivitiesClient client, + string workerProfileId, + CancellationToken cancellation = default) + { + return RemoveServerlessActivityDeclarationCoreAsync( + client, + workerProfileId, + cancellation); + } + /// /// Streams logs from a serverless activity sandbox using task hub metadata already configured on the gRPC channel. /// @@ -84,6 +102,26 @@ static async Task> ListServerlessActivitySa return sandboxes; } + static async Task RemoveServerlessActivityDeclarationCoreAsync( + Proto.ServerlessActivities.ServerlessActivitiesClient client, + string workerProfileId, + CancellationToken cancellation) + { + ArgumentNullException.ThrowIfNull(client); + ValidateRequired(workerProfileId, nameof(workerProfileId), "Worker profile ID is required."); + + Proto.RemoveServerlessActivityDeclarationRequest request = new() + { + WorkerProfileId = workerProfileId, + }; + + using AsyncUnaryCall call = client.RemoveServerlessActivityDeclarationAsync( + request, + headers: null, + cancellationToken: cancellation); + await call.ResponseAsync.ConfigureAwait(false); + } + static async IAsyncEnumerable StreamSandboxLogsCoreAsync( Proto.ServerlessActivities.ServerlessActivitiesClient client, string dtsSandboxIdentifier, diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs index 067bd496..e0e8420d 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs @@ -48,6 +48,23 @@ public async Task ListServerlessActivitySandboxesAsync_SendsRequestAndMapsSandbo mapped.State.Should().Be("Running"); } + [Fact] + public async Task RemoveServerlessActivityDeclarationAsync_SendsRequest() + { + // Arrange + RecordingServerlessLogCallInvoker callInvoker = new(); + ServerlessActivities.ServerlessActivitiesClient client = new(callInvoker); + + // Act + await client.RemoveServerlessActivityDeclarationAsync("default"); + + // Assert + callInvoker.RemoveRequest.Should().NotBeNull(); + callInvoker.RemoveRequest!.WorkerProfileId.Should().Be("default"); + callInvoker.RemoveHeaders.Should().NotContain(header => header.Key == "taskhub"); + callInvoker.UnaryDisposeCount.Should().Be(1); + } + [Fact] public async Task StreamSandboxLogsAsync_SendsRequestAndMapsLines() { @@ -156,6 +173,10 @@ public RecordingServerlessLogCallInvoker(ListServerlessActivitySandboxesResult l public Metadata ListHeaders { get; private set; } = []; + public RemoveServerlessActivityDeclarationRequest? RemoveRequest { get; private set; } + + public Metadata RemoveHeaders { get; private set; } = []; + public int UnaryDisposeCount { get; private set; } public override TResponse BlockingUnaryCall( @@ -173,12 +194,25 @@ public override AsyncUnaryCall AsyncUnaryCall( CallOptions options, TRequest request) { - method.FullName.Should().EndWith("/ListServerlessActivitySandboxes"); - this.ListRequest = (ListServerlessActivitySandboxesRequest)(object)request; - this.ListHeaders = options.Headers ?? []; + if (method.FullName.EndsWith("/ListServerlessActivitySandboxes", StringComparison.Ordinal)) + { + this.ListRequest = (ListServerlessActivitySandboxesRequest)(object)request; + this.ListHeaders = options.Headers ?? []; + + return new AsyncUnaryCall( + Task.FromResult((TResponse)(object)this.listResponse), + Task.FromResult(new Metadata()), + () => new Status(StatusCode.OK, string.Empty), + () => new Metadata(), + () => this.UnaryDisposeCount++); + } + + method.FullName.Should().EndWith("/RemoveServerlessActivityDeclaration"); + this.RemoveRequest = (RemoveServerlessActivityDeclarationRequest)(object)request; + this.RemoveHeaders = options.Headers ?? []; return new AsyncUnaryCall( - Task.FromResult((TResponse)(object)this.listResponse), + Task.FromResult((TResponse)(object)new RemoveServerlessActivityDeclarationResult()), Task.FromResult(new Metadata()), () => new Status(StatusCode.OK, string.Empty), () => new Metadata(), From 489115563597551abdf486894ff28a86a381d5c9 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 20 May 2026 12:40:23 -0700 Subject: [PATCH 16/81] Add serverless activities sample --- Microsoft.DurableTask.sln | 33 +++ README.md | 2 + samples/serverless/README.md | 64 +++++ samples/serverless/declarer/Activities.cs | 32 +++ .../serverless/declarer/NoAuthCredential.cs | 26 ++ samples/serverless/declarer/Orchestrators.cs | 52 ++++ samples/serverless/declarer/Program.cs | 237 ++++++++++++++++++ .../declarer/ServerlessSandboxHttpHost.cs | 100 ++++++++ .../declarer/ServerlessSandboxModels.cs | 22 ++ .../declarer/ServerlessSandboxesController.cs | 119 +++++++++ samples/serverless/declarer/declarer.csproj | 22 ++ .../serverless/remote-worker/Activities.cs | 59 +++++ .../serverless/remote-worker/Containerfile | 34 +++ .../remote-worker/Containerfile.dockerignore | 20 ++ .../remote-worker/NoAuthCredential.cs | 26 ++ samples/serverless/remote-worker/Program.cs | 53 ++++ .../remote-worker/remote-worker.csproj | 23 ++ 17 files changed, 924 insertions(+) create mode 100644 samples/serverless/README.md create mode 100644 samples/serverless/declarer/Activities.cs create mode 100644 samples/serverless/declarer/NoAuthCredential.cs create mode 100644 samples/serverless/declarer/Orchestrators.cs create mode 100644 samples/serverless/declarer/Program.cs create mode 100644 samples/serverless/declarer/ServerlessSandboxHttpHost.cs create mode 100644 samples/serverless/declarer/ServerlessSandboxModels.cs create mode 100644 samples/serverless/declarer/ServerlessSandboxesController.cs create mode 100644 samples/serverless/declarer/declarer.csproj create mode 100644 samples/serverless/remote-worker/Activities.cs create mode 100644 samples/serverless/remote-worker/Containerfile create mode 100644 samples/serverless/remote-worker/Containerfile.dockerignore create mode 100644 samples/serverless/remote-worker/NoAuthCredential.cs create mode 100644 samples/serverless/remote-worker/Program.cs create mode 100644 samples/serverless/remote-worker/remote-worker.csproj diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index 467ee762..e91474aa 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -127,6 +127,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureManagedServerless.Tests", "test\Extensions\AzureManagedServerless.Tests\AzureManagedServerless.Tests.csproj", "{4D50F5B2-4782-486F-A9AA-073D798CC60D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "serverless", "serverless", "{5BD6F026-413E-9AC5-D159-8E8D9F26EF1B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "declarer", "samples\serverless\declarer\declarer.csproj", "{4535F88F-EA1C-4C6F-84D5-93535EE1568C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "remote-worker", "samples\serverless\remote-worker\remote-worker.csproj", "{562E5DB9-761B-4DE9-98CB-C364F6DE558E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -737,6 +743,30 @@ Global {4D50F5B2-4782-486F-A9AA-073D798CC60D}.Release|x64.Build.0 = Release|Any CPU {4D50F5B2-4782-486F-A9AA-073D798CC60D}.Release|x86.ActiveCfg = Release|Any CPU {4D50F5B2-4782-486F-A9AA-073D798CC60D}.Release|x86.Build.0 = Release|Any CPU + {4535F88F-EA1C-4C6F-84D5-93535EE1568C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4535F88F-EA1C-4C6F-84D5-93535EE1568C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4535F88F-EA1C-4C6F-84D5-93535EE1568C}.Debug|x64.ActiveCfg = Debug|Any CPU + {4535F88F-EA1C-4C6F-84D5-93535EE1568C}.Debug|x64.Build.0 = Debug|Any CPU + {4535F88F-EA1C-4C6F-84D5-93535EE1568C}.Debug|x86.ActiveCfg = Debug|Any CPU + {4535F88F-EA1C-4C6F-84D5-93535EE1568C}.Debug|x86.Build.0 = Debug|Any CPU + {4535F88F-EA1C-4C6F-84D5-93535EE1568C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4535F88F-EA1C-4C6F-84D5-93535EE1568C}.Release|Any CPU.Build.0 = Release|Any CPU + {4535F88F-EA1C-4C6F-84D5-93535EE1568C}.Release|x64.ActiveCfg = Release|Any CPU + {4535F88F-EA1C-4C6F-84D5-93535EE1568C}.Release|x64.Build.0 = Release|Any CPU + {4535F88F-EA1C-4C6F-84D5-93535EE1568C}.Release|x86.ActiveCfg = Release|Any CPU + {4535F88F-EA1C-4C6F-84D5-93535EE1568C}.Release|x86.Build.0 = Release|Any CPU + {562E5DB9-761B-4DE9-98CB-C364F6DE558E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {562E5DB9-761B-4DE9-98CB-C364F6DE558E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {562E5DB9-761B-4DE9-98CB-C364F6DE558E}.Debug|x64.ActiveCfg = Debug|Any CPU + {562E5DB9-761B-4DE9-98CB-C364F6DE558E}.Debug|x64.Build.0 = Debug|Any CPU + {562E5DB9-761B-4DE9-98CB-C364F6DE558E}.Debug|x86.ActiveCfg = Debug|Any CPU + {562E5DB9-761B-4DE9-98CB-C364F6DE558E}.Debug|x86.Build.0 = Debug|Any CPU + {562E5DB9-761B-4DE9-98CB-C364F6DE558E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {562E5DB9-761B-4DE9-98CB-C364F6DE558E}.Release|Any CPU.Build.0 = Release|Any CPU + {562E5DB9-761B-4DE9-98CB-C364F6DE558E}.Release|x64.ActiveCfg = Release|Any CPU + {562E5DB9-761B-4DE9-98CB-C364F6DE558E}.Release|x64.Build.0 = Release|Any CPU + {562E5DB9-761B-4DE9-98CB-C364F6DE558E}.Release|x86.ActiveCfg = Release|Any CPU + {562E5DB9-761B-4DE9-98CB-C364F6DE558E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -800,6 +830,9 @@ Global {53193780-CD18-2643-6953-C26F59EAEDF5} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5} {00205C88-F000-28F2-A910-C6FA00E065EE} = {E5637F81-2FB9-4CD7-900D-455363B142A7} {4D50F5B2-4782-486F-A9AA-073D798CC60D} = {00205C88-F000-28F2-A910-C6FA00E065EE} + {5BD6F026-413E-9AC5-D159-8E8D9F26EF1B} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} + {4535F88F-EA1C-4C6F-84D5-93535EE1568C} = {5BD6F026-413E-9AC5-D159-8E8D9F26EF1B} + {562E5DB9-761B-4DE9-98CB-C364F6DE558E} = {5BD6F026-413E-9AC5-D159-8E8D9F26EF1B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71} diff --git a/README.md b/README.md index 7226f201..2094e890 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,8 @@ The Durable Task Scheduler for Azure Functions is a managed backend that is curr This SDK can also be used with the Durable Task Scheduler directly, without any Durable Functions dependency. To get started, sign up for the [Durable Task Scheduler private preview](https://techcommunity.microsoft.com/blog/appsonazureblog/announcing-limited-early-access-of-the-durable-task-scheduler-for-azure-durable-/4286526) and follow the instructions to create a new Durable Task Scheduler instance. Once granted access to the private preview GitHub repository, you can find samples and documentation for getting started [here](https://github.com/Azure/Azure-Functions-Durable-Task-Scheduler-Private-Preview/tree/main/samples/portable-sdk/dotnet/AspNetWebApp#readme). +The [serverless activities sample](samples/serverless/README.md) shows how to declare selected activities for DTS-managed serverless execution and build the remote worker container image separately from the declarer app. + ## Obtaining the Protobuf definitions This project utilizes protobuf definitions from [durabletask-protobuf](https://github.com/microsoft/durabletask-protobuf), which are copied (vendored) into this repository under the `src/Grpc` directory. See the corresponding [README.md](./src/Grpc/README.md) for more information about how to update the protobuf definitions. diff --git a/samples/serverless/README.md b/samples/serverless/README.md new file mode 100644 index 00000000..0a21e54d --- /dev/null +++ b/samples/serverless/README.md @@ -0,0 +1,64 @@ +# Serverless Activities Sample + +This sample shows how to run selected Durable Task activities in DTS-managed serverless sandboxes. + +The sample is intentionally split into two projects: + +| Path | Purpose | +| --- | --- | +| `declarer/` | Runs locally or in a normal app host. It declares the serverless activities, runs local orchestrations and local activities, and can expose HTTP helpers for listing sandboxes and streaming logs. | +| `remote-worker/` | Builds the container image that DTS starts inside a serverless sandbox. It contains only the remote activities. | + +## Build + +```powershell +dotnet build .\samples\serverless\declarer\declarer.csproj +dotnet build .\samples\serverless\remote-worker\remote-worker.csproj +``` + +## Build the remote worker image + +Run from the repository root: + +```powershell +$image = ".azurecr.io/dts-serverless-sample:" +docker build -f .\samples\serverless\remote-worker\Containerfile -t $image . +docker push $image +``` + +## Run a hello orchestration + +```powershell +$env:DTS_ENDPOINT = "https://" +$env:DTS_TASK_HUB = "" +$env:DTS_SERVERLESS_ACTIVITY_IMAGE = ".azurecr.io/dts-serverless-sample:" +$env:DTS_SERVERLESS_CPU = "1000m" +$env:DTS_SERVERLESS_MEMORY = "2048Mi" +$env:DTS_SERVERLESS_MAX_ACTIVITIES = "1" + +# Private preview test deployments may disable auth. +$env:DTS_NO_AUTH = "true" + +dotnet run --project .\samples\serverless\declarer\declarer.csproj -- hello serverless-sample +``` + +Expected output includes both a local activity result and a serverless activity result: + +```text +Runtime status: Completed +Output: "local:serverless-sample | hello from pid=: serverless-sample" +``` + +## Sandbox log helper API + +The declarer can also expose a small HTTP helper API: + +```powershell +dotnet run --project .\samples\serverless\declarer\declarer.csproj -- serve +``` + +Endpoints: + +- `GET /health` +- `GET /serverless/sandboxes?workerProfileId=default` +- `GET /serverless/sandboxes/{dtsSandboxIdentifier}/logs?tail=100` diff --git a/samples/serverless/declarer/Activities.cs b/samples/serverless/declarer/Activities.cs new file mode 100644 index 00000000..b8eedf5a --- /dev/null +++ b/samples/serverless/declarer/Activities.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask; + +namespace Microsoft.DurableTask.Samples.Serverless.Declarer; + +internal static class ServerlessTaskNames +{ + public const string LocalEcho = "LocalEcho"; + public const string RemoteHello = "RemoteHello"; + public const string BurstWork = "BurstWork"; + public const string ResizeImage = "ResizeImage"; + public const string BurstMegaWork = "BurstMegaWork"; + public const string HelloOrchestrator = nameof(HelloOrchestrator); + public const string BurstOrchestrator = nameof(BurstOrchestrator); + public const string ResizeImageOrchestrator = nameof(ResizeImageOrchestrator); + public const string BurstMegaOrchestrator = nameof(BurstMegaOrchestrator); +} + +public sealed record BurstMegaResult(int Index, int Value, string Host, int Pid); + +public sealed record ResizeImageRequest(string SourceUri, int Width, int Height); + +public sealed record ResizeImageResult(string SourceUri, int Width, int Height, string ThumbnailBase64, int SourceFingerprintLength); + +[DurableTask("LocalEcho")] +internal sealed class LocalEchoActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, string input) + => Task.FromResult($"local:{input}"); +} diff --git a/samples/serverless/declarer/NoAuthCredential.cs b/samples/serverless/declarer/NoAuthCredential.cs new file mode 100644 index 00000000..9aa544b8 --- /dev/null +++ b/samples/serverless/declarer/NoAuthCredential.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; + +namespace Microsoft.DurableTask.Samples.Serverless.Declarer; + +/// +/// A no-op TokenCredential for sandbox-hosted workers running against a backend that has +/// authentication disabled. The DTS backend deployment in this POC sets +/// --ClientAuth:DisableAuthentication=true, so any token (or none) is accepted on the +/// gRPC calls. Inside an ADC sandbox there is no managed identity available and no +/// az login session, so would crash +/// during startup. Setting DTS_NO_AUTH=true in the sandbox env causes the SDK to use +/// this credential instead. +/// +internal sealed class NoAuthCredential : TokenCredential +{ + static readonly AccessToken FakeToken = new("dts-no-auth", DateTimeOffset.UtcNow.AddYears(1)); + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => FakeToken; + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => ValueTask.FromResult(FakeToken); +} diff --git a/samples/serverless/declarer/Orchestrators.cs b/samples/serverless/declarer/Orchestrators.cs new file mode 100644 index 00000000..584b86a8 --- /dev/null +++ b/samples/serverless/declarer/Orchestrators.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask; + +namespace Microsoft.DurableTask.Samples.Serverless.Declarer; + +[DurableTask(nameof(HelloOrchestrator))] +internal sealed class HelloOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string input) + { + string localResult = await context.CallActivityAsync(ServerlessTaskNames.LocalEcho, input); + string remoteResult = await context.CallActivityAsync(ServerlessTaskNames.RemoteHello, input); + return $"{localResult} | {remoteResult}"; + } +} + +[DurableTask(nameof(BurstOrchestrator))] +internal sealed class BurstOrchestrator : TaskOrchestrator> +{ + public override async Task> RunAsync(TaskOrchestrationContext context, int input) + { + int activityCount = Math.Clamp(input, 1, 50); + Task[] tasks = Enumerable.Range(1, activityCount) + .Select(i => context.CallActivityAsync(ServerlessTaskNames.BurstWork, i)) + .ToArray(); + + return (await Task.WhenAll(tasks)).ToList(); + } +} + +[DurableTask(nameof(ResizeImageOrchestrator))] +internal sealed class ResizeImageOrchestrator : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, ResizeImageRequest input) + => context.CallActivityAsync(ServerlessTaskNames.ResizeImage, input); +} + +[DurableTask(nameof(BurstMegaOrchestrator))] +internal sealed class BurstMegaOrchestrator : TaskOrchestrator> +{ + public override async Task> RunAsync(TaskOrchestrationContext context, int input) + { + int activityCount = Math.Clamp(input, 1, 100); + Task[] tasks = Enumerable.Range(1, activityCount) + .Select(i => context.CallActivityAsync(ServerlessTaskNames.BurstMegaWork, i)) + .ToArray(); + + return (await Task.WhenAll(tasks)).ToList(); + } +} diff --git a/samples/serverless/declarer/Program.cs b/samples/serverless/declarer/Program.cs new file mode 100644 index 00000000..c03b9c54 --- /dev/null +++ b/samples/serverless/declarer/Program.cs @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Identity; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Samples.Serverless.Declarer; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +DemoCommandParseResult parseResult = TryParseCommand(args, out DemoCommand command); +if (parseResult == DemoCommandParseResult.Invalid) +{ + return; +} + +string endpoint = GetRequiredEnvironmentVariable("DTS_ENDPOINT"); +string taskHub = Environment.GetEnvironmentVariable("DTS_TASK_HUB") + ?? Environment.GetEnvironmentVariable("DTS_TASKHUB") + ?? "ServerlessPocHub"; +bool allowInsecureCredentials = endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase); +TokenCredential credential = string.Equals(Environment.GetEnvironmentVariable("DTS_NO_AUTH"), "true", StringComparison.OrdinalIgnoreCase) + ? new NoAuthCredential() + : new DefaultAzureCredential(); + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Logging.AddSimpleConsole(options => +{ + options.SingleLine = true; + options.UseUtcTimestamp = true; + options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ "; +}); + +builder.Services.AddDurableTaskWorker(workerBuilder => +{ + workerBuilder.AddTasks(tasks => tasks.AddAllGeneratedTasks()); + workerBuilder.UseDurableTaskScheduler(options => + { + options.EndpointAddress = endpoint; + options.TaskHubName = taskHub; + options.Credential = credential; + options.AllowInsecureCredentials = allowInsecureCredentials; + }); + + workerBuilder.DeclareServerlessActivities(options => + { + options.TaskHub = taskHub; + options.WorkerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID") ?? "default"; + options.ContainerImage = Environment.GetEnvironmentVariable("DTS_SERVERLESS_ACTIVITY_IMAGE") + ?? "serverless-remote-worker:local"; + options.Cpu = Environment.GetEnvironmentVariable("DTS_SERVERLESS_CPU") ?? "1000m"; + options.Memory = Environment.GetEnvironmentVariable("DTS_SERVERLESS_MEMORY") ?? "2048Mi"; + options.MaxConcurrentActivities = GetIntEnv("DTS_SERVERLESS_MAX_ACTIVITIES", 100); + options.EnvironmentVariables["DTS_ENDPOINT"] = endpoint; + AddDeclarationEnvironmentVariableIfPresent(options.EnvironmentVariables, "DTS_NO_AUTH"); + AddDeclarationEnvironmentVariableIfPresent(options.EnvironmentVariables, "DTS_SERVERLESS_IDLE_TIMEOUT_SECONDS"); + AddServerlessActivityNames(options.ActivityNames); + }); +}); + +builder.Services.AddDurableTaskClient(clientBuilder => +{ + clientBuilder.UseDurableTaskScheduler(options => + { + options.EndpointAddress = endpoint; + options.TaskHubName = taskHub; + options.Credential = credential; + options.AllowInsecureCredentials = allowInsecureCredentials; + }); +}); + +using IHost host = builder.Build(); + +if (parseResult == DemoCommandParseResult.Execute) +{ + await host.StartAsync(); + + DurableTaskClient client = host.Services.GetRequiredService(); + await ExecuteCommandAsync(client, command); + + await host.StopAsync(); + return; +} + +if (parseResult == DemoCommandParseResult.RunHttpApi) +{ + await ServerlessSandboxHttpHost.RunAsync( + args, + endpoint, + taskHub, + credential, + allowInsecureCredentials); + return; +} + +await host.RunAsync(); + +static string GetRequiredEnvironmentVariable(string name) + => Environment.GetEnvironmentVariable(name) + ?? throw new InvalidOperationException($"An environment variable named '{name}' is required."); + +static int GetIntEnv(string name, int defaultValue) +{ + string? value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + return int.TryParse(value, out int parsed) && parsed > 0 + ? parsed + : throw new InvalidOperationException($"Environment variable '{name}' must be a positive integer."); +} + +static void AddDeclarationEnvironmentVariableIfPresent(IDictionary environmentVariables, string name) +{ + string? value = Environment.GetEnvironmentVariable(name); + if (!string.IsNullOrWhiteSpace(value)) + { + environmentVariables[name] = value; + } +} + +static void AddServerlessActivityNames(ICollection activityNames) +{ + activityNames.Add(ServerlessTaskNames.RemoteHello); + activityNames.Add(ServerlessTaskNames.BurstWork); + activityNames.Add(ServerlessTaskNames.ResizeImage); + activityNames.Add(ServerlessTaskNames.BurstMegaWork); +} + +static DemoCommandParseResult TryParseCommand(string[] args, out DemoCommand command) +{ + command = DemoCommand.RunWorker; + + if (args.Length == 0) + { + return DemoCommandParseResult.RunWorker; + } + + string verb = args[0].ToLowerInvariant(); + switch (verb) + { + case "hello": + command = DemoCommand.Hello(args.Length > 1 ? args[1] : "world"); + return DemoCommandParseResult.Execute; + case "burst": + int burstCount = args.Length > 1 && int.TryParse(args[1], out int parsedCount) ? parsedCount : 10; + command = DemoCommand.Burst(burstCount); + return DemoCommandParseResult.Execute; + case "resize": + string sourceUri = args.Length > 1 ? args[1] : "https://example.invalid/sample.png"; + int width = args.Length > 2 && int.TryParse(args[2], out int parsedWidth) ? parsedWidth : 160; + int height = args.Length > 3 && int.TryParse(args[3], out int parsedHeight) ? parsedHeight : 90; + command = DemoCommand.Resize(new ResizeImageRequest(sourceUri, width, height)); + return DemoCommandParseResult.Execute; + case "burst-mega": + int megaCount = args.Length > 1 && int.TryParse(args[1], out int parsedMega) ? parsedMega : 50; + command = DemoCommand.BurstMega(megaCount); + return DemoCommandParseResult.Execute; + case "serve": + case "http": + case "api": + command = DemoCommand.RunHttpApi; + return DemoCommandParseResult.RunHttpApi; + default: + Console.WriteLine("Unknown command. Supported commands: hello [name], burst [count], burst-mega [count], resize [url] [width] [height], serve."); + Environment.ExitCode = 1; + return DemoCommandParseResult.Invalid; + } +} + +static async Task ExecuteCommandAsync(DurableTaskClient client, DemoCommand command) +{ + switch (command.Kind) + { + case DemoCommandKind.Hello: + await RunAndPrintAsync(client, ServerlessTaskNames.HelloOrchestrator, command.HelloInput!); + break; + case DemoCommandKind.Burst: + await RunAndPrintAsync(client, ServerlessTaskNames.BurstOrchestrator, command.BurstCount!.Value); + break; + case DemoCommandKind.Resize: + await RunAndPrintAsync(client, ServerlessTaskNames.ResizeImageOrchestrator, command.ResizeRequest!); + break; + case DemoCommandKind.BurstMega: + await RunAndPrintAsync(client, ServerlessTaskNames.BurstMegaOrchestrator, command.BurstCount!.Value); + break; + } +} + +static async Task RunAndPrintAsync(DurableTaskClient client, string orchestratorName, object input) +{ + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestratorName, input: input); + OrchestrationMetadata? result = await client.WaitForInstanceCompletionAsync(instanceId, getInputsAndOutputs: true); + Console.WriteLine($"Started orchestration: {instanceId}"); + Console.WriteLine($"Runtime status: {result?.RuntimeStatus}"); + Console.WriteLine($"Output: {result?.SerializedOutput ?? ""}"); +} + +internal enum DemoCommandParseResult +{ + RunWorker, + Execute, + RunHttpApi, + Invalid, +} + +internal enum DemoCommandKind +{ + RunWorker, + RunHttpApi, + Hello, + Burst, + Resize, + BurstMega, +} + +internal sealed record DemoCommand(DemoCommandKind Kind, string? HelloInput = null, int? BurstCount = null, ResizeImageRequest? ResizeRequest = null) +{ + public static DemoCommand RunWorker { get; } = new(DemoCommandKind.RunWorker); + + public static DemoCommand RunHttpApi { get; } = new(DemoCommandKind.RunHttpApi); + + public static DemoCommand Hello(string input) => new(DemoCommandKind.Hello, HelloInput: input); + + public static DemoCommand Burst(int input) => new(DemoCommandKind.Burst, BurstCount: input); + + public static DemoCommand Resize(ResizeImageRequest request) => new(DemoCommandKind.Resize, ResizeRequest: request); + + public static DemoCommand BurstMega(int count) => new(DemoCommandKind.BurstMega) { BurstCount = count }; +} diff --git a/samples/serverless/declarer/ServerlessSandboxHttpHost.cs b/samples/serverless/declarer/ServerlessSandboxHttpHost.cs new file mode 100644 index 00000000..a4f1cba1 --- /dev/null +++ b/samples/serverless/declarer/ServerlessSandboxHttpHost.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Grpc.Core; +using Grpc.Net.Client; +using Proto = Microsoft.DurableTask.Protobuf.Serverless; + +namespace Microsoft.DurableTask.Samples.Serverless.Declarer; + +internal static class ServerlessSandboxHttpHost +{ + const string DefaultResourceId = "https://durabletask.io"; + + public static async Task RunAsync( + string[] args, + string endpoint, + string taskHub, + TokenCredential credential, + bool allowInsecureCredentials) + { + string normalizedEndpoint = NormalizeEndpoint(endpoint); + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + builder.Services.AddSingleton(new ServerlessSandboxHttpOptions( + normalizedEndpoint, + taskHub, + Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID") ?? "default", + Environment.GetEnvironmentVariable("DTS_RESOURCE_ID") ?? DefaultResourceId, + allowInsecureCredentials || normalizedEndpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase))); + builder.Services.AddSingleton(credential); + builder.Services.AddSingleton(CreateChannel); + builder.Services.AddSingleton(provider => new Proto.ServerlessActivities.ServerlessActivitiesClient( + provider.GetRequiredService())); + builder.Services.AddControllers().AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + }); + + string? urls = Environment.GetEnvironmentVariable("DTS_DEMO_HTTP_URLS") + ?? Environment.GetEnvironmentVariable("ASPNETCORE_URLS"); + if (string.IsNullOrWhiteSpace(urls)) + { + builder.WebHost.UseUrls("http://localhost:5188"); + } + else + { + builder.WebHost.UseUrls(urls); + } + + WebApplication app = builder.Build(); + app.MapControllers(); + await app.RunAsync(); + } + + static string NormalizeEndpoint(string endpoint) + { + string trimmedEndpoint = endpoint.Trim(); + string normalizedEndpoint = trimmedEndpoint.Contains("://", StringComparison.Ordinal) + ? trimmedEndpoint + : $"https://{trimmedEndpoint}"; + + if (!Uri.TryCreate(normalizedEndpoint, UriKind.Absolute, out Uri? uri) + || string.IsNullOrWhiteSpace(uri.Host)) + { + throw new InvalidOperationException($"DTS_ENDPOINT '{endpoint}' is not a valid absolute URI or host name."); + } + + return normalizedEndpoint; + } + + static GrpcChannel CreateChannel(IServiceProvider provider) + { + ServerlessSandboxHttpOptions options = provider.GetRequiredService(); + TokenCredential credential = provider.GetRequiredService(); + TokenRequestContext tokenRequestContext = new([$"{options.ResourceId}/.default"]); + + ChannelCredentials channelCredentials = options.Endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + ? ChannelCredentials.SecureSsl + : ChannelCredentials.Insecure; + CallCredentials callCredentials = CallCredentials.FromInterceptor(async (context, metadata) => + { + metadata.Add("taskhub", options.TaskHub); + metadata.Add("x-user-agent", "durabletask-dotnet-serverless-sample"); + + AccessToken token = await credential.GetTokenAsync(tokenRequestContext, context.CancellationToken); + metadata.Add("Authorization", $"Bearer {token.Token}"); + }); + + return GrpcChannel.ForAddress(options.Endpoint, new GrpcChannelOptions + { + Credentials = ChannelCredentials.Create(channelCredentials, callCredentials), + UnsafeUseInsecureChannelCallCredentials = options.AllowInsecureCredentials, + }); + } +} \ No newline at end of file diff --git a/samples/serverless/declarer/ServerlessSandboxModels.cs b/samples/serverless/declarer/ServerlessSandboxModels.cs new file mode 100644 index 00000000..1b327967 --- /dev/null +++ b/samples/serverless/declarer/ServerlessSandboxModels.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Samples.Serverless.Declarer; + +public sealed record ServerlessSandboxHttpOptions( + string Endpoint, + string TaskHub, + string DefaultWorkerProfileId, + string ResourceId, + bool AllowInsecureCredentials); + +public sealed record ServerlessSandboxListResponse( + string TaskHub, + string WorkerProfileId, + IReadOnlyList Sandboxes); + +public sealed record ServerlessSandboxSummary( + string DtsSandboxIdentifier, + string WorkerProfileId, + string State, + DateTimeOffset? CreatedAt); \ No newline at end of file diff --git a/samples/serverless/declarer/ServerlessSandboxesController.cs b/samples/serverless/declarer/ServerlessSandboxesController.cs new file mode 100644 index 00000000..04de4f42 --- /dev/null +++ b/samples/serverless/declarer/ServerlessSandboxesController.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Grpc.Core; +using Microsoft.AspNetCore.Mvc; +using Microsoft.DurableTask.Client.AzureManaged; +using Proto = Microsoft.DurableTask.Protobuf.Serverless; + +namespace Microsoft.DurableTask.Samples.Serverless.Declarer; + +[ApiController] +[Route("")] +public sealed class HealthController : ControllerBase +{ + [HttpGet("health")] + public ActionResult GetHealth() => this.Ok(new { status = "ok" }); +} + +[ApiController] +[Route("serverless/sandboxes")] +public sealed class ServerlessSandboxesController( + Proto.ServerlessActivities.ServerlessActivitiesClient client, + ServerlessSandboxHttpOptions options) : ControllerBase +{ + readonly Proto.ServerlessActivities.ServerlessActivitiesClient client = client; + readonly ServerlessSandboxHttpOptions options = options; + + [HttpGet] + public async Task> ListSandboxes( + [FromQuery] string? workerProfileId, + CancellationToken cancellationToken) + { + try + { + string resolvedWorkerProfileId = string.IsNullOrWhiteSpace(workerProfileId) + ? this.options.DefaultWorkerProfileId + : workerProfileId; + IReadOnlyList sandboxes = await this.client.ListServerlessActivitySandboxesAsync( + resolvedWorkerProfileId, + cancellationToken); + ServerlessSandboxListResponse response = new( + this.options.TaskHub, + resolvedWorkerProfileId, + sandboxes.Select(sandbox => new ServerlessSandboxSummary( + sandbox.DtsSandboxIdentifier, + sandbox.WorkerProfileId, + sandbox.State, + sandbox.CreatedAt == default ? null : sandbox.CreatedAt)) + .ToArray()); + + return this.Ok(response); + } + catch (RpcException ex) + { + return ToGrpcProblem(ex); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return this.Problem(ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + } + + [HttpGet("{dtsSandboxIdentifier}/logs")] + public async Task StreamLogs( + [FromRoute] string dtsSandboxIdentifier, + [FromQuery] int? tail, + CancellationToken cancellationToken) + { + this.Response.ContentType = "text/plain; charset=utf-8"; + try + { + int resolvedTail = Math.Clamp(tail ?? 100, 0, 300); + await foreach (ServerlessSandboxLogLine line in this.client.StreamSandboxLogsAsync( + dtsSandboxIdentifier, + resolvedTail, + cancellationToken)) + { + await this.Response.WriteAsync(FormatLogLine(line), cancellationToken); + await this.Response.WriteAsync(Environment.NewLine, cancellationToken); + await this.Response.Body.FlushAsync(cancellationToken); + } + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.Cancelled) + { + } + catch (OperationCanceledException) + { + } + catch (RpcException ex) when (!this.Response.HasStarted) + { + this.Response.StatusCode = StatusCodes.Status502BadGateway; + await this.Response.WriteAsync($"DTS serverless log stream failed: {ex.Status.Detail}", cancellationToken); + } + catch (Exception ex) when ((ex is ArgumentException or InvalidOperationException) && !this.Response.HasStarted) + { + this.Response.StatusCode = StatusCodes.Status400BadRequest; + await this.Response.WriteAsync(ex.Message, cancellationToken); + } + } + + ActionResult ToGrpcProblem(RpcException ex) + => this.Problem( + detail: ex.Status.Detail, + statusCode: StatusCodes.Status502BadGateway, + title: "DTS serverless gRPC call failed"); + + static string FormatLogLine(ServerlessSandboxLogLine line) + { + if (!string.IsNullOrWhiteSpace(line.RawLine)) + { + return line.RawLine; + } + + string timestamp = line.Timestamp == default ? string.Empty : line.Timestamp.ToString("O"); + string stream = string.IsNullOrWhiteSpace(line.Stream) ? "log" : line.Stream; + string tag = string.IsNullOrWhiteSpace(line.Tag) ? string.Empty : $"[{line.Tag}] "; + return $"{timestamp} {stream}: {tag}{line.Message}".Trim(); + } +} \ No newline at end of file diff --git a/samples/serverless/declarer/declarer.csproj b/samples/serverless/declarer/declarer.csproj new file mode 100644 index 00000000..a195af8d --- /dev/null +++ b/samples/serverless/declarer/declarer.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + + + + + + + + + + + + + + + + diff --git a/samples/serverless/remote-worker/Activities.cs b/samples/serverless/remote-worker/Activities.cs new file mode 100644 index 00000000..84e5977c --- /dev/null +++ b/samples/serverless/remote-worker/Activities.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Security.Cryptography; +using System.Text; +using Microsoft.DurableTask; + +namespace Microsoft.DurableTask.Samples.Serverless.RemoteWorker; + +public sealed record BurstMegaResult(int Index, int Value, string Host, int Pid); + +public sealed record ResizeImageRequest(string SourceUri, int Width, int Height); + +public sealed record ResizeImageResult(string SourceUri, int Width, int Height, string ThumbnailBase64, int SourceFingerprintLength); + +[DurableTask("RemoteHello")] +internal sealed class RemoteHelloActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, string input) + => Task.FromResult($"hello from {Environment.MachineName} pid={Environment.ProcessId}: {input}"); +} + +[DurableTask("BurstWork")] +internal sealed class BurstWorkActivity : TaskActivity +{ + public override async Task RunAsync(TaskActivityContext context, int input) + { + await Task.Delay(TimeSpan.FromSeconds(2)); + return input * 2; + } +} + +[DurableTask("BurstMegaWork")] +internal sealed class BurstMegaWorkActivity : TaskActivity +{ + public override async Task RunAsync(TaskActivityContext context, int input) + { + int durationSeconds = 30; + if (int.TryParse(Environment.GetEnvironmentVariable("DEMO_BURSTMEGA_DURATION_SECONDS"), out int parsed) && parsed > 0) + { + durationSeconds = parsed; + } + + await Task.Delay(TimeSpan.FromSeconds(durationSeconds)); + return new BurstMegaResult(input, input * 2, Environment.MachineName, Environment.ProcessId); + } +} + +[DurableTask("ResizeImage")] +internal sealed class ResizeImageActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, ResizeImageRequest input) + { + byte[] fingerprint = SHA256.HashData(Encoding.UTF8.GetBytes($"{input.SourceUri}|{input.Width}|{input.Height}")); + string thumbnail = Convert.ToBase64String(fingerprint[..24]); + ResizeImageResult result = new(input.SourceUri, input.Width, input.Height, thumbnail, fingerprint.Length); + return Task.FromResult(result); + } +} diff --git a/samples/serverless/remote-worker/Containerfile b/samples/serverless/remote-worker/Containerfile new file mode 100644 index 00000000..18f24b23 --- /dev/null +++ b/samples/serverless/remote-worker/Containerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1.7 + +FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +ARG TARGETARCH + +COPY . /src/durabletask-dotnet + +WORKDIR /src/durabletask-dotnet/samples/serverless/remote-worker +RUN case "$TARGETARCH" in \ + amd64) runtime_identifier=linux-x64 ;; \ + arm64) runtime_identifier=linux-arm64 ;; \ + *) echo "Unsupported target architecture: $TARGETARCH" >&2; exit 1 ;; \ + esac \ + && dotnet publish remote-worker.csproj \ + -c Release \ + -r "$runtime_identifier" \ + --self-contained false \ + -o /app/publish \ + --configfile /src/durabletask-dotnet/nuget.config \ + /p:DebugSymbols=false \ + /p:DebugType=None \ + && find /app/publish -type f \( -name '*.xml' -o -name '*.pdb' \) -delete + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +ENV ASPNETCORE_URLS=http://+:8080 + +EXPOSE 8080 + +COPY --from=build /app/publish ./ + +ENTRYPOINT ["dotnet", "ServerlessRemoteWorker.dll"] diff --git a/samples/serverless/remote-worker/Containerfile.dockerignore b/samples/serverless/remote-worker/Containerfile.dockerignore new file mode 100644 index 00000000..9b8836cf --- /dev/null +++ b/samples/serverless/remote-worker/Containerfile.dockerignore @@ -0,0 +1,20 @@ +** +!Directory.Build.props +!Directory.Build.targets +!Directory.Packages.props +!global.json +!nuget.config +!stylecop.json +!eng/ +!eng/** +!src/ +!src/** +!samples/ +!samples/Directory.Build.props +!samples/Directory.Packages.props +!samples/serverless/ +!samples/serverless/** +**/bin/ +**/obj/ +**/.git/ +**/.tunnel-url \ No newline at end of file diff --git a/samples/serverless/remote-worker/NoAuthCredential.cs b/samples/serverless/remote-worker/NoAuthCredential.cs new file mode 100644 index 00000000..fc89b9a9 --- /dev/null +++ b/samples/serverless/remote-worker/NoAuthCredential.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; + +namespace Microsoft.DurableTask.Samples.Serverless.RemoteWorker; + +/// +/// A no-op TokenCredential for sandbox-hosted workers running against a backend that has +/// authentication disabled. The DTS backend deployment in this POC sets +/// --ClientAuth:DisableAuthentication=true, so any token (or none) is accepted on the +/// gRPC calls. Inside an ADC sandbox there is no managed identity available and no +/// az login session, so would crash +/// during startup. Setting DTS_NO_AUTH=true in the sandbox env causes the SDK to use +/// this credential instead. +/// +internal sealed class NoAuthCredential : TokenCredential +{ + static readonly AccessToken FakeToken = new("dts-no-auth", DateTimeOffset.UtcNow.AddYears(1)); + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => FakeToken; + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => ValueTask.FromResult(FakeToken); +} diff --git a/samples/serverless/remote-worker/Program.cs b/samples/serverless/remote-worker/Program.cs new file mode 100644 index 00000000..483ab823 --- /dev/null +++ b/samples/serverless/remote-worker/Program.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Identity; +using Microsoft.DurableTask.Samples.Serverless.RemoteWorker; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +string endpoint = GetRequiredEnvironmentVariable("DTS_ENDPOINT"); +string taskHub = Environment.GetEnvironmentVariable("DTS_TASK_HUB") + ?? Environment.GetEnvironmentVariable("DTS_TASKHUB") + ?? "ServerlessPocHub"; +bool allowInsecureCredentials = endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase); +TokenCredential credential = string.Equals(Environment.GetEnvironmentVariable("DTS_NO_AUTH"), "true", StringComparison.OrdinalIgnoreCase) + ? new NoAuthCredential() + : new DefaultAzureCredential(); + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Logging.AddSimpleConsole(options => +{ + options.SingleLine = true; + options.UseUtcTimestamp = true; + options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ "; +}); + +builder.Services.AddDurableTaskWorker(workerBuilder => +{ + workerBuilder.AddTasks(tasks => + { + tasks.AddActivity(); + tasks.AddActivity(); + tasks.AddActivity(); + tasks.AddActivity(); + }); + workerBuilder.UseDurableTaskScheduler(options => + { + options.EndpointAddress = endpoint; + options.TaskHubName = taskHub; + options.Credential = credential; + options.AllowInsecureCredentials = allowInsecureCredentials; + }); + workerBuilder.UseServerlessWorker(); +}); + +await builder.Build().RunAsync(); + +static string GetRequiredEnvironmentVariable(string name) + => Environment.GetEnvironmentVariable(name) + ?? throw new InvalidOperationException($"An environment variable named '{name}' is required."); diff --git a/samples/serverless/remote-worker/remote-worker.csproj b/samples/serverless/remote-worker/remote-worker.csproj new file mode 100644 index 00000000..41050b33 --- /dev/null +++ b/samples/serverless/remote-worker/remote-worker.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + enable + ServerlessRemoteWorker + Microsoft.DurableTask.Samples.Serverless.RemoteWorker + + + + + + + + + + + + + + + From 42b615356a54a23b02c82a591eb13b64e7b17799 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 20 May 2026 12:59:58 -0700 Subject: [PATCH 17/81] Remove serverless sample no-auth credential --- samples/serverless/README.md | 3 --- .../serverless/declarer/NoAuthCredential.cs | 26 ------------------- samples/serverless/declarer/Program.cs | 4 +-- .../declarer/ServerlessSandboxHttpHost.cs | 17 ++++++++---- .../remote-worker/NoAuthCredential.cs | 26 ------------------- samples/serverless/remote-worker/Program.cs | 4 +-- 6 files changed, 16 insertions(+), 64 deletions(-) delete mode 100644 samples/serverless/declarer/NoAuthCredential.cs delete mode 100644 samples/serverless/remote-worker/NoAuthCredential.cs diff --git a/samples/serverless/README.md b/samples/serverless/README.md index 0a21e54d..b9c5d64f 100644 --- a/samples/serverless/README.md +++ b/samples/serverless/README.md @@ -36,9 +36,6 @@ $env:DTS_SERVERLESS_CPU = "1000m" $env:DTS_SERVERLESS_MEMORY = "2048Mi" $env:DTS_SERVERLESS_MAX_ACTIVITIES = "1" -# Private preview test deployments may disable auth. -$env:DTS_NO_AUTH = "true" - dotnet run --project .\samples\serverless\declarer\declarer.csproj -- hello serverless-sample ``` diff --git a/samples/serverless/declarer/NoAuthCredential.cs b/samples/serverless/declarer/NoAuthCredential.cs deleted file mode 100644 index 9aa544b8..00000000 --- a/samples/serverless/declarer/NoAuthCredential.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Azure.Core; - -namespace Microsoft.DurableTask.Samples.Serverless.Declarer; - -/// -/// A no-op TokenCredential for sandbox-hosted workers running against a backend that has -/// authentication disabled. The DTS backend deployment in this POC sets -/// --ClientAuth:DisableAuthentication=true, so any token (or none) is accepted on the -/// gRPC calls. Inside an ADC sandbox there is no managed identity available and no -/// az login session, so would crash -/// during startup. Setting DTS_NO_AUTH=true in the sandbox env causes the SDK to use -/// this credential instead. -/// -internal sealed class NoAuthCredential : TokenCredential -{ - static readonly AccessToken FakeToken = new("dts-no-auth", DateTimeOffset.UtcNow.AddYears(1)); - - public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) - => FakeToken; - - public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) - => ValueTask.FromResult(FakeToken); -} diff --git a/samples/serverless/declarer/Program.cs b/samples/serverless/declarer/Program.cs index c03b9c54..61a61697 100644 --- a/samples/serverless/declarer/Program.cs +++ b/samples/serverless/declarer/Program.cs @@ -24,8 +24,8 @@ ?? Environment.GetEnvironmentVariable("DTS_TASKHUB") ?? "ServerlessPocHub"; bool allowInsecureCredentials = endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase); -TokenCredential credential = string.Equals(Environment.GetEnvironmentVariable("DTS_NO_AUTH"), "true", StringComparison.OrdinalIgnoreCase) - ? new NoAuthCredential() +TokenCredential? credential = string.Equals(Environment.GetEnvironmentVariable("DTS_NO_AUTH"), "true", StringComparison.OrdinalIgnoreCase) + ? null : new DefaultAzureCredential(); HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); diff --git a/samples/serverless/declarer/ServerlessSandboxHttpHost.cs b/samples/serverless/declarer/ServerlessSandboxHttpHost.cs index a4f1cba1..65fbc0b1 100644 --- a/samples/serverless/declarer/ServerlessSandboxHttpHost.cs +++ b/samples/serverless/declarer/ServerlessSandboxHttpHost.cs @@ -20,7 +20,7 @@ public static async Task RunAsync( string[] args, string endpoint, string taskHub, - TokenCredential credential, + TokenCredential? credential, bool allowInsecureCredentials) { string normalizedEndpoint = NormalizeEndpoint(endpoint); @@ -31,7 +31,11 @@ public static async Task RunAsync( Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID") ?? "default", Environment.GetEnvironmentVariable("DTS_RESOURCE_ID") ?? DefaultResourceId, allowInsecureCredentials || normalizedEndpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase))); - builder.Services.AddSingleton(credential); + if (credential is not null) + { + builder.Services.AddSingleton(credential); + } + builder.Services.AddSingleton(CreateChannel); builder.Services.AddSingleton(provider => new Proto.ServerlessActivities.ServerlessActivitiesClient( provider.GetRequiredService())); @@ -76,7 +80,7 @@ static string NormalizeEndpoint(string endpoint) static GrpcChannel CreateChannel(IServiceProvider provider) { ServerlessSandboxHttpOptions options = provider.GetRequiredService(); - TokenCredential credential = provider.GetRequiredService(); + TokenCredential? credential = provider.GetService(); TokenRequestContext tokenRequestContext = new([$"{options.ResourceId}/.default"]); ChannelCredentials channelCredentials = options.Endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase) @@ -87,8 +91,11 @@ static GrpcChannel CreateChannel(IServiceProvider provider) metadata.Add("taskhub", options.TaskHub); metadata.Add("x-user-agent", "durabletask-dotnet-serverless-sample"); - AccessToken token = await credential.GetTokenAsync(tokenRequestContext, context.CancellationToken); - metadata.Add("Authorization", $"Bearer {token.Token}"); + if (credential is not null) + { + AccessToken token = await credential.GetTokenAsync(tokenRequestContext, context.CancellationToken); + metadata.Add("Authorization", $"Bearer {token.Token}"); + } }); return GrpcChannel.ForAddress(options.Endpoint, new GrpcChannelOptions diff --git a/samples/serverless/remote-worker/NoAuthCredential.cs b/samples/serverless/remote-worker/NoAuthCredential.cs deleted file mode 100644 index fc89b9a9..00000000 --- a/samples/serverless/remote-worker/NoAuthCredential.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Azure.Core; - -namespace Microsoft.DurableTask.Samples.Serverless.RemoteWorker; - -/// -/// A no-op TokenCredential for sandbox-hosted workers running against a backend that has -/// authentication disabled. The DTS backend deployment in this POC sets -/// --ClientAuth:DisableAuthentication=true, so any token (or none) is accepted on the -/// gRPC calls. Inside an ADC sandbox there is no managed identity available and no -/// az login session, so would crash -/// during startup. Setting DTS_NO_AUTH=true in the sandbox env causes the SDK to use -/// this credential instead. -/// -internal sealed class NoAuthCredential : TokenCredential -{ - static readonly AccessToken FakeToken = new("dts-no-auth", DateTimeOffset.UtcNow.AddYears(1)); - - public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) - => FakeToken; - - public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) - => ValueTask.FromResult(FakeToken); -} diff --git a/samples/serverless/remote-worker/Program.cs b/samples/serverless/remote-worker/Program.cs index 483ab823..1107f760 100644 --- a/samples/serverless/remote-worker/Program.cs +++ b/samples/serverless/remote-worker/Program.cs @@ -15,8 +15,8 @@ ?? Environment.GetEnvironmentVariable("DTS_TASKHUB") ?? "ServerlessPocHub"; bool allowInsecureCredentials = endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase); -TokenCredential credential = string.Equals(Environment.GetEnvironmentVariable("DTS_NO_AUTH"), "true", StringComparison.OrdinalIgnoreCase) - ? new NoAuthCredential() +TokenCredential? credential = string.Equals(Environment.GetEnvironmentVariable("DTS_NO_AUTH"), "true", StringComparison.OrdinalIgnoreCase) + ? null : new DefaultAzureCredential(); HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); From 71c9359c8687ef043d7a85cd57b84e0b30600cd3 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 20 May 2026 13:15:30 -0700 Subject: [PATCH 18/81] Simplify serverless sample auth and HTTP helper --- samples/serverless/README.md | 4 +- samples/serverless/declarer/Program.cs | 9 +- .../declarer/ServerlessSandboxHttpHost.cs | 87 ++++--------------- .../declarer/ServerlessSandboxModels.cs | 5 +- .../declarer/ServerlessSandboxesController.cs | 5 +- samples/serverless/declarer/declarer.csproj | 1 - samples/serverless/remote-worker/Program.cs | 6 -- .../remote-worker/remote-worker.csproj | 2 - .../Client/ServerlessActivitiesClient.cs | 59 +++++++++++++ ...vitiesClientServiceCollectionExtensions.cs | 58 +++++++++++++ ...rverlessActivitiesClientExtensionsTests.cs | 37 ++++++++ 11 files changed, 178 insertions(+), 95 deletions(-) create mode 100644 src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClient.cs create mode 100644 src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientServiceCollectionExtensions.cs diff --git a/samples/serverless/README.md b/samples/serverless/README.md index b9c5d64f..9230978d 100644 --- a/samples/serverless/README.md +++ b/samples/serverless/README.md @@ -28,6 +28,8 @@ docker push $image ## Run a hello orchestration +The declarer uses `DefaultAzureCredential`; sign in with Azure CLI or configure another supported Azure identity before running it. + ```powershell $env:DTS_ENDPOINT = "https://" $env:DTS_TASK_HUB = "" @@ -48,7 +50,7 @@ Output: "local:serverless-sample | hello from pid=: serverless-sa ## Sandbox log helper API -The declarer can also expose a small HTTP helper API: +The declarer can also expose a small HTTP helper API. The helper reuses the SDK's DTS serverless client registration instead of setting up gRPC channels directly. ```powershell dotnet run --project .\samples\serverless\declarer\declarer.csproj -- serve diff --git a/samples/serverless/declarer/Program.cs b/samples/serverless/declarer/Program.cs index 61a61697..c8818774 100644 --- a/samples/serverless/declarer/Program.cs +++ b/samples/serverless/declarer/Program.cs @@ -24,9 +24,7 @@ ?? Environment.GetEnvironmentVariable("DTS_TASKHUB") ?? "ServerlessPocHub"; bool allowInsecureCredentials = endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase); -TokenCredential? credential = string.Equals(Environment.GetEnvironmentVariable("DTS_NO_AUTH"), "true", StringComparison.OrdinalIgnoreCase) - ? null - : new DefaultAzureCredential(); +TokenCredential credential = new DefaultAzureCredential(); HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); builder.Logging.AddSimpleConsole(options => @@ -57,7 +55,6 @@ options.Memory = Environment.GetEnvironmentVariable("DTS_SERVERLESS_MEMORY") ?? "2048Mi"; options.MaxConcurrentActivities = GetIntEnv("DTS_SERVERLESS_MAX_ACTIVITIES", 100); options.EnvironmentVariables["DTS_ENDPOINT"] = endpoint; - AddDeclarationEnvironmentVariableIfPresent(options.EnvironmentVariables, "DTS_NO_AUTH"); AddDeclarationEnvironmentVariableIfPresent(options.EnvironmentVariables, "DTS_SERVERLESS_IDLE_TIMEOUT_SECONDS"); AddServerlessActivityNames(options.ActivityNames); }); @@ -90,11 +87,9 @@ if (parseResult == DemoCommandParseResult.RunHttpApi) { await ServerlessSandboxHttpHost.RunAsync( - args, endpoint, taskHub, - credential, - allowInsecureCredentials); + credential); return; } diff --git a/samples/serverless/declarer/ServerlessSandboxHttpHost.cs b/samples/serverless/declarer/ServerlessSandboxHttpHost.cs index 65fbc0b1..ce41adf1 100644 --- a/samples/serverless/declarer/ServerlessSandboxHttpHost.cs +++ b/samples/serverless/declarer/ServerlessSandboxHttpHost.cs @@ -1,49 +1,38 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Text.Json.Serialization; using Azure.Core; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; using Microsoft.Extensions.DependencyInjection; -using Grpc.Core; -using Grpc.Net.Client; -using Proto = Microsoft.DurableTask.Protobuf.Serverless; namespace Microsoft.DurableTask.Samples.Serverless.Declarer; internal static class ServerlessSandboxHttpHost { - const string DefaultResourceId = "https://durabletask.io"; - public static async Task RunAsync( - string[] args, string endpoint, string taskHub, - TokenCredential? credential, - bool allowInsecureCredentials) + TokenCredential credential) { - string normalizedEndpoint = NormalizeEndpoint(endpoint); - WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + WebApplicationBuilder builder = WebApplication.CreateBuilder(); builder.Services.AddSingleton(new ServerlessSandboxHttpOptions( - normalizedEndpoint, taskHub, - Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID") ?? "default", - Environment.GetEnvironmentVariable("DTS_RESOURCE_ID") ?? DefaultResourceId, - allowInsecureCredentials || normalizedEndpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase))); - if (credential is not null) - { - builder.Services.AddSingleton(credential); - } - - builder.Services.AddSingleton(CreateChannel); - builder.Services.AddSingleton(provider => new Proto.ServerlessActivities.ServerlessActivitiesClient( - provider.GetRequiredService())); - builder.Services.AddControllers().AddJsonOptions(options => + Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID") ?? "default")); + builder.Services.AddDurableTaskClient(clientBuilder => { - options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); - options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + clientBuilder.UseDurableTaskScheduler(options => + { + options.EndpointAddress = endpoint; + options.TaskHubName = taskHub; + options.Credential = credential; + options.AllowInsecureCredentials = endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase); + }); }); + builder.Services.AddDurableTaskSchedulerServerlessActivitiesClient(); + builder.Services.AddControllers(); string? urls = Environment.GetEnvironmentVariable("DTS_DEMO_HTTP_URLS") ?? Environment.GetEnvironmentVariable("ASPNETCORE_URLS"); @@ -60,48 +49,4 @@ public static async Task RunAsync( app.MapControllers(); await app.RunAsync(); } - - static string NormalizeEndpoint(string endpoint) - { - string trimmedEndpoint = endpoint.Trim(); - string normalizedEndpoint = trimmedEndpoint.Contains("://", StringComparison.Ordinal) - ? trimmedEndpoint - : $"https://{trimmedEndpoint}"; - - if (!Uri.TryCreate(normalizedEndpoint, UriKind.Absolute, out Uri? uri) - || string.IsNullOrWhiteSpace(uri.Host)) - { - throw new InvalidOperationException($"DTS_ENDPOINT '{endpoint}' is not a valid absolute URI or host name."); - } - - return normalizedEndpoint; - } - - static GrpcChannel CreateChannel(IServiceProvider provider) - { - ServerlessSandboxHttpOptions options = provider.GetRequiredService(); - TokenCredential? credential = provider.GetService(); - TokenRequestContext tokenRequestContext = new([$"{options.ResourceId}/.default"]); - - ChannelCredentials channelCredentials = options.Endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase) - ? ChannelCredentials.SecureSsl - : ChannelCredentials.Insecure; - CallCredentials callCredentials = CallCredentials.FromInterceptor(async (context, metadata) => - { - metadata.Add("taskhub", options.TaskHub); - metadata.Add("x-user-agent", "durabletask-dotnet-serverless-sample"); - - if (credential is not null) - { - AccessToken token = await credential.GetTokenAsync(tokenRequestContext, context.CancellationToken); - metadata.Add("Authorization", $"Bearer {token.Token}"); - } - }); - - return GrpcChannel.ForAddress(options.Endpoint, new GrpcChannelOptions - { - Credentials = ChannelCredentials.Create(channelCredentials, callCredentials), - UnsafeUseInsecureChannelCallCredentials = options.AllowInsecureCredentials, - }); - } -} \ No newline at end of file +} diff --git a/samples/serverless/declarer/ServerlessSandboxModels.cs b/samples/serverless/declarer/ServerlessSandboxModels.cs index 1b327967..a74309ef 100644 --- a/samples/serverless/declarer/ServerlessSandboxModels.cs +++ b/samples/serverless/declarer/ServerlessSandboxModels.cs @@ -4,11 +4,8 @@ namespace Microsoft.DurableTask.Samples.Serverless.Declarer; public sealed record ServerlessSandboxHttpOptions( - string Endpoint, string TaskHub, - string DefaultWorkerProfileId, - string ResourceId, - bool AllowInsecureCredentials); + string DefaultWorkerProfileId); public sealed record ServerlessSandboxListResponse( string TaskHub, diff --git a/samples/serverless/declarer/ServerlessSandboxesController.cs b/samples/serverless/declarer/ServerlessSandboxesController.cs index 04de4f42..c14cfc4e 100644 --- a/samples/serverless/declarer/ServerlessSandboxesController.cs +++ b/samples/serverless/declarer/ServerlessSandboxesController.cs @@ -4,7 +4,6 @@ using Grpc.Core; using Microsoft.AspNetCore.Mvc; using Microsoft.DurableTask.Client.AzureManaged; -using Proto = Microsoft.DurableTask.Protobuf.Serverless; namespace Microsoft.DurableTask.Samples.Serverless.Declarer; @@ -19,10 +18,10 @@ public sealed class HealthController : ControllerBase [ApiController] [Route("serverless/sandboxes")] public sealed class ServerlessSandboxesController( - Proto.ServerlessActivities.ServerlessActivitiesClient client, + ServerlessActivitiesClient client, ServerlessSandboxHttpOptions options) : ControllerBase { - readonly Proto.ServerlessActivities.ServerlessActivitiesClient client = client; + readonly ServerlessActivitiesClient client = client; readonly ServerlessSandboxHttpOptions options = options; [HttpGet] diff --git a/samples/serverless/declarer/declarer.csproj b/samples/serverless/declarer/declarer.csproj index a195af8d..65647d91 100644 --- a/samples/serverless/declarer/declarer.csproj +++ b/samples/serverless/declarer/declarer.csproj @@ -8,7 +8,6 @@ - diff --git a/samples/serverless/remote-worker/Program.cs b/samples/serverless/remote-worker/Program.cs index 1107f760..e1de4972 100644 --- a/samples/serverless/remote-worker/Program.cs +++ b/samples/serverless/remote-worker/Program.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Azure.Core; -using Azure.Identity; using Microsoft.DurableTask.Samples.Serverless.RemoteWorker; using Microsoft.DurableTask.Worker; using Microsoft.DurableTask.Worker.AzureManaged; @@ -15,9 +13,6 @@ ?? Environment.GetEnvironmentVariable("DTS_TASKHUB") ?? "ServerlessPocHub"; bool allowInsecureCredentials = endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase); -TokenCredential? credential = string.Equals(Environment.GetEnvironmentVariable("DTS_NO_AUTH"), "true", StringComparison.OrdinalIgnoreCase) - ? null - : new DefaultAzureCredential(); HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); builder.Logging.AddSimpleConsole(options => @@ -40,7 +35,6 @@ { options.EndpointAddress = endpoint; options.TaskHubName = taskHub; - options.Credential = credential; options.AllowInsecureCredentials = allowInsecureCredentials; }); workerBuilder.UseServerlessWorker(); diff --git a/samples/serverless/remote-worker/remote-worker.csproj b/samples/serverless/remote-worker/remote-worker.csproj index 41050b33..c358c3cb 100644 --- a/samples/serverless/remote-worker/remote-worker.csproj +++ b/samples/serverless/remote-worker/remote-worker.csproj @@ -9,8 +9,6 @@ - - diff --git a/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClient.cs b/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClient.cs new file mode 100644 index 00000000..8801d01b --- /dev/null +++ b/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClient.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Proto = Microsoft.DurableTask.Protobuf.Serverless; + +namespace Microsoft.DurableTask.Client.AzureManaged; + +/// +/// Client for DTS serverless activity management operations. +/// +public sealed class ServerlessActivitiesClient +{ + readonly Proto.ServerlessActivities.ServerlessActivitiesClient client; + + /// + /// Initializes a new instance of the class. + /// + /// The generated gRPC client used to call DTS serverless management operations. + internal ServerlessActivitiesClient(Proto.ServerlessActivities.ServerlessActivitiesClient client) + { + this.client = client; + } + + /// + /// Lists DTS-managed sandboxes for a serverless activity worker profile. + /// + /// The worker profile ID to list sandboxes for. + /// The cancellation token used to cancel the request. + /// The sandboxes currently known to DTS for the worker profile. + public Task> ListServerlessActivitySandboxesAsync( + string workerProfileId, + CancellationToken cancellation = default) + => this.client.ListServerlessActivitySandboxesAsync(workerProfileId, cancellation); + + /// + /// Removes a serverless activity declaration for a worker profile. + /// + /// The worker profile ID whose declaration should be removed. + /// The cancellation token used to cancel the request. + /// A task that completes when DTS removes the declaration. + public Task RemoveServerlessActivityDeclarationAsync( + string workerProfileId, + CancellationToken cancellation = default) + => this.client.RemoveServerlessActivityDeclarationAsync(workerProfileId, cancellation); + + /// + /// Streams logs from a serverless activity sandbox. + /// + /// The DTS sandbox identifier to stream logs from. + /// The number of historical log lines to include before streaming live logs. + /// The cancellation token used to stop streaming. + /// An async stream of sandbox log lines. + public IAsyncEnumerable StreamSandboxLogsAsync( + string dtsSandboxIdentifier, + int tail = 100, + CancellationToken cancellation = default) + => this.client.StreamSandboxLogsAsync(dtsSandboxIdentifier, tail, cancellation); +} diff --git a/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientServiceCollectionExtensions.cs b/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientServiceCollectionExtensions.cs new file mode 100644 index 00000000..ff0ed57a --- /dev/null +++ b/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientServiceCollectionExtensions.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Grpc.Net.Client; +using Microsoft.DurableTask.Client.Grpc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Proto = Microsoft.DurableTask.Protobuf.Serverless; + +namespace Microsoft.DurableTask.Client.AzureManaged; + +/// +/// Extension methods for registering DTS serverless activity management clients. +/// +public static class ServerlessActivitiesClientServiceCollectionExtensions +{ + /// + /// Adds a DTS serverless activity management client using the default Durable Task client configuration. + /// + /// The service collection to configure. + /// The original service collection, for call chaining. + public static IServiceCollection AddDurableTaskSchedulerServerlessActivitiesClient(this IServiceCollection services) + => AddDurableTaskSchedulerServerlessActivitiesClient(services, Options.DefaultName); + + /// + /// Adds a DTS serverless activity management client using a named Durable Task client configuration. + /// + /// The service collection to configure. + /// The Durable Task client name whose scheduler channel should be reused. + /// The original service collection, for call chaining. + public static IServiceCollection AddDurableTaskSchedulerServerlessActivitiesClient( + this IServiceCollection services, + string clientName) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(clientName); + + services.AddSingleton(provider => + { + GrpcDurableTaskClientOptions options = provider + .GetRequiredService>() + .Get(clientName); + + if (options.CallInvoker is { } callInvoker) + { + return new ServerlessActivitiesClient(new Proto.ServerlessActivities.ServerlessActivitiesClient(callInvoker)); + } + + if (options.Channel is GrpcChannel channel) + { + return new ServerlessActivitiesClient(new Proto.ServerlessActivities.ServerlessActivitiesClient(channel.CreateCallInvoker())); + } + + throw new InvalidOperationException("DTS serverless activity management requires a configured Durable Task Scheduler client."); + }); + return services; + } +} diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs index e0e8420d..f742c15d 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs @@ -4,7 +4,10 @@ using FluentAssertions; using Google.Protobuf.WellKnownTypes; using Grpc.Core; +using Microsoft.DurableTask.Client.Grpc; using Microsoft.DurableTask.Protobuf.Serverless; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Xunit; namespace Microsoft.DurableTask.Client.AzureManaged.Tests; @@ -48,6 +51,40 @@ public async Task ListServerlessActivitySandboxesAsync_SendsRequestAndMapsSandbo mapped.State.Should().Be("Running"); } + [Fact] + public async Task AddDurableTaskSchedulerServerlessActivitiesClient_UsesConfiguredDurableTaskClientInvoker() + { + // Arrange + RecordingServerlessLogCallInvoker callInvoker = new( + new ListServerlessActivitySandboxesResult + { + Sandboxes = + { + new ServerlessActivitySandbox + { + DtsSandboxIdentifier = "sandbox-1", + WorkerProfileId = "default", + State = "Running", + }, + }, + }); + ServiceCollection services = new(); + services.AddOptions(Options.DefaultName) + .Configure(options => options.CallInvoker = callInvoker); + services.AddDurableTaskSchedulerServerlessActivitiesClient(); + + using ServiceProvider provider = services.BuildServiceProvider(); + ServerlessActivitiesClient client = provider.GetRequiredService(); + + // Act + IReadOnlyList sandboxes = await client.ListServerlessActivitySandboxesAsync("default"); + + // Assert + callInvoker.ListRequest.Should().NotBeNull(); + callInvoker.ListRequest!.WorkerProfileId.Should().Be("default"); + sandboxes.Should().ContainSingle().Which.DtsSandboxIdentifier.Should().Be("sandbox-1"); + } + [Fact] public async Task RemoveServerlessActivityDeclarationAsync_SendsRequest() { From 55c9e36a251b4c0d4e677f69269e8cf9d9f34d91 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 20 May 2026 13:22:37 -0700 Subject: [PATCH 19/81] Remove insecure credential sample wiring --- samples/serverless/declarer/Program.cs | 3 --- samples/serverless/declarer/ServerlessSandboxHttpHost.cs | 1 - samples/serverless/remote-worker/Program.cs | 2 -- 3 files changed, 6 deletions(-) diff --git a/samples/serverless/declarer/Program.cs b/samples/serverless/declarer/Program.cs index c8818774..8a23811d 100644 --- a/samples/serverless/declarer/Program.cs +++ b/samples/serverless/declarer/Program.cs @@ -23,7 +23,6 @@ string taskHub = Environment.GetEnvironmentVariable("DTS_TASK_HUB") ?? Environment.GetEnvironmentVariable("DTS_TASKHUB") ?? "ServerlessPocHub"; -bool allowInsecureCredentials = endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase); TokenCredential credential = new DefaultAzureCredential(); HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); @@ -42,7 +41,6 @@ options.EndpointAddress = endpoint; options.TaskHubName = taskHub; options.Credential = credential; - options.AllowInsecureCredentials = allowInsecureCredentials; }); workerBuilder.DeclareServerlessActivities(options => @@ -67,7 +65,6 @@ options.EndpointAddress = endpoint; options.TaskHubName = taskHub; options.Credential = credential; - options.AllowInsecureCredentials = allowInsecureCredentials; }); }); diff --git a/samples/serverless/declarer/ServerlessSandboxHttpHost.cs b/samples/serverless/declarer/ServerlessSandboxHttpHost.cs index ce41adf1..d4bb92e7 100644 --- a/samples/serverless/declarer/ServerlessSandboxHttpHost.cs +++ b/samples/serverless/declarer/ServerlessSandboxHttpHost.cs @@ -28,7 +28,6 @@ public static async Task RunAsync( options.EndpointAddress = endpoint; options.TaskHubName = taskHub; options.Credential = credential; - options.AllowInsecureCredentials = endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase); }); }); builder.Services.AddDurableTaskSchedulerServerlessActivitiesClient(); diff --git a/samples/serverless/remote-worker/Program.cs b/samples/serverless/remote-worker/Program.cs index e1de4972..d29fbc83 100644 --- a/samples/serverless/remote-worker/Program.cs +++ b/samples/serverless/remote-worker/Program.cs @@ -12,7 +12,6 @@ string taskHub = Environment.GetEnvironmentVariable("DTS_TASK_HUB") ?? Environment.GetEnvironmentVariable("DTS_TASKHUB") ?? "ServerlessPocHub"; -bool allowInsecureCredentials = endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase); HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); builder.Logging.AddSimpleConsole(options => @@ -35,7 +34,6 @@ { options.EndpointAddress = endpoint; options.TaskHubName = taskHub; - options.AllowInsecureCredentials = allowInsecureCredentials; }); workerBuilder.UseServerlessWorker(); }); From 560557cd828fe50a1b0c04a13c606a57ed12a0ed Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 20 May 2026 17:20:44 -0700 Subject: [PATCH 20/81] simple serverless sample --- Microsoft.DurableTask.sln | 2 +- samples/serverless/README.md | 21 +- samples/serverless/declarer/Activities.cs | 32 --- samples/serverless/declarer/Orchestrators.cs | 52 ---- samples/serverless/declarer/Program.cs | 229 ------------------ samples/serverless/main-app/Activities.cs | 10 + samples/serverless/main-app/Orchestrators.cs | 16 ++ samples/serverless/main-app/Program.cs | 140 +++++++++++ .../ServerlessSandboxHttpHost.cs | 7 +- .../ServerlessSandboxModels.cs | 4 +- .../ServerlessSandboxesController.cs | 4 +- .../main-app.csproj} | 2 + .../serverless/remote-worker/Activities.cs | 46 ---- samples/serverless/remote-worker/Program.cs | 3 - ...ActivityWorkerRegistrationHostedService.cs | 2 +- 15 files changed, 188 insertions(+), 382 deletions(-) delete mode 100644 samples/serverless/declarer/Activities.cs delete mode 100644 samples/serverless/declarer/Orchestrators.cs delete mode 100644 samples/serverless/declarer/Program.cs create mode 100644 samples/serverless/main-app/Activities.cs create mode 100644 samples/serverless/main-app/Orchestrators.cs create mode 100644 samples/serverless/main-app/Program.cs rename samples/serverless/{declarer => main-app}/ServerlessSandboxHttpHost.cs (89%) rename samples/serverless/{declarer => main-app}/ServerlessSandboxModels.cs (83%) rename samples/serverless/{declarer => main-app}/ServerlessSandboxesController.cs (98%) rename samples/serverless/{declarer/declarer.csproj => main-app/main-app.csproj} (86%) diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index e91474aa..051dd2c1 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -129,7 +129,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureManagedServerless.Test EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "serverless", "serverless", "{5BD6F026-413E-9AC5-D159-8E8D9F26EF1B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "declarer", "samples\serverless\declarer\declarer.csproj", "{4535F88F-EA1C-4C6F-84D5-93535EE1568C}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "main-app", "samples\serverless\main-app\main-app.csproj", "{4535F88F-EA1C-4C6F-84D5-93535EE1568C}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "remote-worker", "samples\serverless\remote-worker\remote-worker.csproj", "{562E5DB9-761B-4DE9-98CB-C364F6DE558E}" EndProject diff --git a/samples/serverless/README.md b/samples/serverless/README.md index 9230978d..a47ef90c 100644 --- a/samples/serverless/README.md +++ b/samples/serverless/README.md @@ -6,13 +6,13 @@ The sample is intentionally split into two projects: | Path | Purpose | | --- | --- | -| `declarer/` | Runs locally or in a normal app host. It declares the serverless activities, runs local orchestrations and local activities, and can expose HTTP helpers for listing sandboxes and streaming logs. | -| `remote-worker/` | Builds the container image that DTS starts inside a serverless sandbox. It contains only the remote activities. | +| `main-app/` | Runs locally or in a normal app host. It declares the serverless activity, starts one hello orchestration, and can expose HTTP helpers for listing sandboxes and streaming logs. | +| `remote-worker/` | Builds the container image that DTS starts inside a serverless sandbox. It contains the remote hello activity. | ## Build ```powershell -dotnet build .\samples\serverless\declarer\declarer.csproj +dotnet build .\samples\serverless\main-app\main-app.csproj dotnet build .\samples\serverless\remote-worker\remote-worker.csproj ``` @@ -28,7 +28,7 @@ docker push $image ## Run a hello orchestration -The declarer uses `DefaultAzureCredential`; sign in with Azure CLI or configure another supported Azure identity before running it. +The main app uses `DefaultAzureCredential`; sign in with Azure CLI or configure another supported Azure identity before running it. ```powershell $env:DTS_ENDPOINT = "https://" @@ -37,23 +37,24 @@ $env:DTS_SERVERLESS_ACTIVITY_IMAGE = ".azurecr.io/dts-serverless-sampl $env:DTS_SERVERLESS_CPU = "1000m" $env:DTS_SERVERLESS_MEMORY = "2048Mi" $env:DTS_SERVERLESS_MAX_ACTIVITIES = "1" +$env:DTS_SAMPLE_HELLO_INPUT = "serverless-sample" -dotnet run --project .\samples\serverless\declarer\declarer.csproj -- hello serverless-sample +dotnet run --project .\samples\serverless\main-app\main-app.csproj ``` -Expected output includes both a local activity result and a serverless activity result: +Expected output includes the serverless activity result: ```text Runtime status: Completed -Output: "local:serverless-sample | hello from pid=: serverless-sample" +Output: "hello from pid=: serverless-sample" ``` -## Sandbox log helper API +## Sandbox helper API -The declarer can also expose a small HTTP helper API. The helper reuses the SDK's DTS serverless client registration instead of setting up gRPC channels directly. +The main app can also expose a small HTTP helper API. The helper reuses the SDK's DTS serverless client registration instead of setting up gRPC channels directly. ```powershell -dotnet run --project .\samples\serverless\declarer\declarer.csproj -- serve +dotnet run --project .\samples\serverless\main-app\main-app.csproj -- serve ``` Endpoints: diff --git a/samples/serverless/declarer/Activities.cs b/samples/serverless/declarer/Activities.cs deleted file mode 100644 index b8eedf5a..00000000 --- a/samples/serverless/declarer/Activities.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.DurableTask; - -namespace Microsoft.DurableTask.Samples.Serverless.Declarer; - -internal static class ServerlessTaskNames -{ - public const string LocalEcho = "LocalEcho"; - public const string RemoteHello = "RemoteHello"; - public const string BurstWork = "BurstWork"; - public const string ResizeImage = "ResizeImage"; - public const string BurstMegaWork = "BurstMegaWork"; - public const string HelloOrchestrator = nameof(HelloOrchestrator); - public const string BurstOrchestrator = nameof(BurstOrchestrator); - public const string ResizeImageOrchestrator = nameof(ResizeImageOrchestrator); - public const string BurstMegaOrchestrator = nameof(BurstMegaOrchestrator); -} - -public sealed record BurstMegaResult(int Index, int Value, string Host, int Pid); - -public sealed record ResizeImageRequest(string SourceUri, int Width, int Height); - -public sealed record ResizeImageResult(string SourceUri, int Width, int Height, string ThumbnailBase64, int SourceFingerprintLength); - -[DurableTask("LocalEcho")] -internal sealed class LocalEchoActivity : TaskActivity -{ - public override Task RunAsync(TaskActivityContext context, string input) - => Task.FromResult($"local:{input}"); -} diff --git a/samples/serverless/declarer/Orchestrators.cs b/samples/serverless/declarer/Orchestrators.cs deleted file mode 100644 index 584b86a8..00000000 --- a/samples/serverless/declarer/Orchestrators.cs +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.DurableTask; - -namespace Microsoft.DurableTask.Samples.Serverless.Declarer; - -[DurableTask(nameof(HelloOrchestrator))] -internal sealed class HelloOrchestrator : TaskOrchestrator -{ - public override async Task RunAsync(TaskOrchestrationContext context, string input) - { - string localResult = await context.CallActivityAsync(ServerlessTaskNames.LocalEcho, input); - string remoteResult = await context.CallActivityAsync(ServerlessTaskNames.RemoteHello, input); - return $"{localResult} | {remoteResult}"; - } -} - -[DurableTask(nameof(BurstOrchestrator))] -internal sealed class BurstOrchestrator : TaskOrchestrator> -{ - public override async Task> RunAsync(TaskOrchestrationContext context, int input) - { - int activityCount = Math.Clamp(input, 1, 50); - Task[] tasks = Enumerable.Range(1, activityCount) - .Select(i => context.CallActivityAsync(ServerlessTaskNames.BurstWork, i)) - .ToArray(); - - return (await Task.WhenAll(tasks)).ToList(); - } -} - -[DurableTask(nameof(ResizeImageOrchestrator))] -internal sealed class ResizeImageOrchestrator : TaskOrchestrator -{ - public override Task RunAsync(TaskOrchestrationContext context, ResizeImageRequest input) - => context.CallActivityAsync(ServerlessTaskNames.ResizeImage, input); -} - -[DurableTask(nameof(BurstMegaOrchestrator))] -internal sealed class BurstMegaOrchestrator : TaskOrchestrator> -{ - public override async Task> RunAsync(TaskOrchestrationContext context, int input) - { - int activityCount = Math.Clamp(input, 1, 100); - Task[] tasks = Enumerable.Range(1, activityCount) - .Select(i => context.CallActivityAsync(ServerlessTaskNames.BurstMegaWork, i)) - .ToArray(); - - return (await Task.WhenAll(tasks)).ToList(); - } -} diff --git a/samples/serverless/declarer/Program.cs b/samples/serverless/declarer/Program.cs deleted file mode 100644 index 8a23811d..00000000 --- a/samples/serverless/declarer/Program.cs +++ /dev/null @@ -1,229 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Azure.Core; -using Azure.Identity; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Client; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.DurableTask.Samples.Serverless.Declarer; -using Microsoft.DurableTask.Worker; -using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -DemoCommandParseResult parseResult = TryParseCommand(args, out DemoCommand command); -if (parseResult == DemoCommandParseResult.Invalid) -{ - return; -} - -string endpoint = GetRequiredEnvironmentVariable("DTS_ENDPOINT"); -string taskHub = Environment.GetEnvironmentVariable("DTS_TASK_HUB") - ?? Environment.GetEnvironmentVariable("DTS_TASKHUB") - ?? "ServerlessPocHub"; -TokenCredential credential = new DefaultAzureCredential(); - -HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); -builder.Logging.AddSimpleConsole(options => -{ - options.SingleLine = true; - options.UseUtcTimestamp = true; - options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ "; -}); - -builder.Services.AddDurableTaskWorker(workerBuilder => -{ - workerBuilder.AddTasks(tasks => tasks.AddAllGeneratedTasks()); - workerBuilder.UseDurableTaskScheduler(options => - { - options.EndpointAddress = endpoint; - options.TaskHubName = taskHub; - options.Credential = credential; - }); - - workerBuilder.DeclareServerlessActivities(options => - { - options.TaskHub = taskHub; - options.WorkerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID") ?? "default"; - options.ContainerImage = Environment.GetEnvironmentVariable("DTS_SERVERLESS_ACTIVITY_IMAGE") - ?? "serverless-remote-worker:local"; - options.Cpu = Environment.GetEnvironmentVariable("DTS_SERVERLESS_CPU") ?? "1000m"; - options.Memory = Environment.GetEnvironmentVariable("DTS_SERVERLESS_MEMORY") ?? "2048Mi"; - options.MaxConcurrentActivities = GetIntEnv("DTS_SERVERLESS_MAX_ACTIVITIES", 100); - options.EnvironmentVariables["DTS_ENDPOINT"] = endpoint; - AddDeclarationEnvironmentVariableIfPresent(options.EnvironmentVariables, "DTS_SERVERLESS_IDLE_TIMEOUT_SECONDS"); - AddServerlessActivityNames(options.ActivityNames); - }); -}); - -builder.Services.AddDurableTaskClient(clientBuilder => -{ - clientBuilder.UseDurableTaskScheduler(options => - { - options.EndpointAddress = endpoint; - options.TaskHubName = taskHub; - options.Credential = credential; - }); -}); - -using IHost host = builder.Build(); - -if (parseResult == DemoCommandParseResult.Execute) -{ - await host.StartAsync(); - - DurableTaskClient client = host.Services.GetRequiredService(); - await ExecuteCommandAsync(client, command); - - await host.StopAsync(); - return; -} - -if (parseResult == DemoCommandParseResult.RunHttpApi) -{ - await ServerlessSandboxHttpHost.RunAsync( - endpoint, - taskHub, - credential); - return; -} - -await host.RunAsync(); - -static string GetRequiredEnvironmentVariable(string name) - => Environment.GetEnvironmentVariable(name) - ?? throw new InvalidOperationException($"An environment variable named '{name}' is required."); - -static int GetIntEnv(string name, int defaultValue) -{ - string? value = Environment.GetEnvironmentVariable(name); - if (string.IsNullOrWhiteSpace(value)) - { - return defaultValue; - } - - return int.TryParse(value, out int parsed) && parsed > 0 - ? parsed - : throw new InvalidOperationException($"Environment variable '{name}' must be a positive integer."); -} - -static void AddDeclarationEnvironmentVariableIfPresent(IDictionary environmentVariables, string name) -{ - string? value = Environment.GetEnvironmentVariable(name); - if (!string.IsNullOrWhiteSpace(value)) - { - environmentVariables[name] = value; - } -} - -static void AddServerlessActivityNames(ICollection activityNames) -{ - activityNames.Add(ServerlessTaskNames.RemoteHello); - activityNames.Add(ServerlessTaskNames.BurstWork); - activityNames.Add(ServerlessTaskNames.ResizeImage); - activityNames.Add(ServerlessTaskNames.BurstMegaWork); -} - -static DemoCommandParseResult TryParseCommand(string[] args, out DemoCommand command) -{ - command = DemoCommand.RunWorker; - - if (args.Length == 0) - { - return DemoCommandParseResult.RunWorker; - } - - string verb = args[0].ToLowerInvariant(); - switch (verb) - { - case "hello": - command = DemoCommand.Hello(args.Length > 1 ? args[1] : "world"); - return DemoCommandParseResult.Execute; - case "burst": - int burstCount = args.Length > 1 && int.TryParse(args[1], out int parsedCount) ? parsedCount : 10; - command = DemoCommand.Burst(burstCount); - return DemoCommandParseResult.Execute; - case "resize": - string sourceUri = args.Length > 1 ? args[1] : "https://example.invalid/sample.png"; - int width = args.Length > 2 && int.TryParse(args[2], out int parsedWidth) ? parsedWidth : 160; - int height = args.Length > 3 && int.TryParse(args[3], out int parsedHeight) ? parsedHeight : 90; - command = DemoCommand.Resize(new ResizeImageRequest(sourceUri, width, height)); - return DemoCommandParseResult.Execute; - case "burst-mega": - int megaCount = args.Length > 1 && int.TryParse(args[1], out int parsedMega) ? parsedMega : 50; - command = DemoCommand.BurstMega(megaCount); - return DemoCommandParseResult.Execute; - case "serve": - case "http": - case "api": - command = DemoCommand.RunHttpApi; - return DemoCommandParseResult.RunHttpApi; - default: - Console.WriteLine("Unknown command. Supported commands: hello [name], burst [count], burst-mega [count], resize [url] [width] [height], serve."); - Environment.ExitCode = 1; - return DemoCommandParseResult.Invalid; - } -} - -static async Task ExecuteCommandAsync(DurableTaskClient client, DemoCommand command) -{ - switch (command.Kind) - { - case DemoCommandKind.Hello: - await RunAndPrintAsync(client, ServerlessTaskNames.HelloOrchestrator, command.HelloInput!); - break; - case DemoCommandKind.Burst: - await RunAndPrintAsync(client, ServerlessTaskNames.BurstOrchestrator, command.BurstCount!.Value); - break; - case DemoCommandKind.Resize: - await RunAndPrintAsync(client, ServerlessTaskNames.ResizeImageOrchestrator, command.ResizeRequest!); - break; - case DemoCommandKind.BurstMega: - await RunAndPrintAsync(client, ServerlessTaskNames.BurstMegaOrchestrator, command.BurstCount!.Value); - break; - } -} - -static async Task RunAndPrintAsync(DurableTaskClient client, string orchestratorName, object input) -{ - string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestratorName, input: input); - OrchestrationMetadata? result = await client.WaitForInstanceCompletionAsync(instanceId, getInputsAndOutputs: true); - Console.WriteLine($"Started orchestration: {instanceId}"); - Console.WriteLine($"Runtime status: {result?.RuntimeStatus}"); - Console.WriteLine($"Output: {result?.SerializedOutput ?? ""}"); -} - -internal enum DemoCommandParseResult -{ - RunWorker, - Execute, - RunHttpApi, - Invalid, -} - -internal enum DemoCommandKind -{ - RunWorker, - RunHttpApi, - Hello, - Burst, - Resize, - BurstMega, -} - -internal sealed record DemoCommand(DemoCommandKind Kind, string? HelloInput = null, int? BurstCount = null, ResizeImageRequest? ResizeRequest = null) -{ - public static DemoCommand RunWorker { get; } = new(DemoCommandKind.RunWorker); - - public static DemoCommand RunHttpApi { get; } = new(DemoCommandKind.RunHttpApi); - - public static DemoCommand Hello(string input) => new(DemoCommandKind.Hello, HelloInput: input); - - public static DemoCommand Burst(int input) => new(DemoCommandKind.Burst, BurstCount: input); - - public static DemoCommand Resize(ResizeImageRequest request) => new(DemoCommandKind.Resize, ResizeRequest: request); - - public static DemoCommand BurstMega(int count) => new(DemoCommandKind.BurstMega) { BurstCount = count }; -} diff --git a/samples/serverless/main-app/Activities.cs b/samples/serverless/main-app/Activities.cs new file mode 100644 index 00000000..73c5caee --- /dev/null +++ b/samples/serverless/main-app/Activities.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Samples.Serverless.MainApp; + +internal static class ServerlessTaskNames +{ + public const string RemoteHello = "RemoteHello"; + public const string HelloOrchestrator = nameof(HelloOrchestrator); +} diff --git a/samples/serverless/main-app/Orchestrators.cs b/samples/serverless/main-app/Orchestrators.cs new file mode 100644 index 00000000..b5fedd88 --- /dev/null +++ b/samples/serverless/main-app/Orchestrators.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask; + +namespace Microsoft.DurableTask.Samples.Serverless.MainApp; + +[DurableTask(nameof(HelloOrchestrator))] +internal sealed class HelloOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string input) + { + string remoteResult = await context.CallActivityAsync(ServerlessTaskNames.RemoteHello, input); + return remoteResult; + } +} diff --git a/samples/serverless/main-app/Program.cs b/samples/serverless/main-app/Program.cs new file mode 100644 index 00000000..e1704e58 --- /dev/null +++ b/samples/serverless/main-app/Program.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Identity; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Samples.Serverless.MainApp; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +string endpoint = GetRequiredEnvironmentVariable("DTS_ENDPOINT"); +string taskHub = Environment.GetEnvironmentVariable("DTS_TASK_HUB") + ?? Environment.GetEnvironmentVariable("DTS_TASKHUB") + ?? "ServerlessPocHub"; +string workerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID") ?? "default"; +string serverlessActivityImage = Environment.GetEnvironmentVariable("DTS_SERVERLESS_ACTIVITY_IMAGE") + ?? "serverless-remote-worker:local"; +string helloInput = Environment.GetEnvironmentVariable("DTS_SAMPLE_HELLO_INPUT") ?? "serverless-sample"; +TokenCredential credential = new DefaultAzureCredential(); +DemoCommand command = ParseCommand(args, helloInput); + +if (command.Kind == DemoCommandKind.Serve) +{ + await ServerlessSandboxHttpHost.RunAsync( + endpoint, + taskHub, + workerProfileId, + credential); + return; +} + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Logging.AddSimpleConsole(options => +{ + options.SingleLine = true; + options.UseUtcTimestamp = true; + options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ "; +}); + +builder.Services.AddDurableTaskWorker(workerBuilder => +{ + workerBuilder.AddTasks(tasks => tasks.AddAllGeneratedTasks()); + workerBuilder.UseDurableTaskScheduler(options => + { + options.EndpointAddress = endpoint; + options.TaskHubName = taskHub; + options.Credential = credential; + }); + + workerBuilder.DeclareServerlessActivities(options => + { + options.TaskHub = taskHub; + options.WorkerProfileId = workerProfileId; + options.ContainerImage = serverlessActivityImage; + options.Cpu = Environment.GetEnvironmentVariable("DTS_SERVERLESS_CPU") ?? "1000m"; + options.Memory = Environment.GetEnvironmentVariable("DTS_SERVERLESS_MEMORY") ?? "2048Mi"; + options.MaxConcurrentActivities = GetIntEnv("DTS_SERVERLESS_MAX_ACTIVITIES", 1); + options.EnvironmentVariables["DTS_ENDPOINT"] = endpoint; + options.ActivityNames.Add(ServerlessTaskNames.RemoteHello); + }); +}); + +builder.Services.AddDurableTaskClient(clientBuilder => +{ + clientBuilder.UseDurableTaskScheduler(options => + { + options.EndpointAddress = endpoint; + options.TaskHubName = taskHub; + options.Credential = credential; + }); +}); + +using IHost host = builder.Build(); + +await host.StartAsync(); + +DurableTaskClient client = host.Services.GetRequiredService(); +string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( + ServerlessTaskNames.HelloOrchestrator, + input: command.HelloInput); +OrchestrationMetadata? result = await client.WaitForInstanceCompletionAsync( + instanceId, + getInputsAndOutputs: true); + +Console.WriteLine($"Started orchestration: {instanceId}"); +Console.WriteLine($"Runtime status: {result?.RuntimeStatus}"); +Console.WriteLine($"Output: {result?.SerializedOutput ?? ""}"); + +await host.StopAsync(); + +static string GetRequiredEnvironmentVariable(string name) + => Environment.GetEnvironmentVariable(name) + ?? throw new InvalidOperationException($"An environment variable named '{name}' is required."); + +static int GetIntEnv(string name, int defaultValue) +{ + string? value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + return int.TryParse(value, out int parsed) && parsed > 0 + ? parsed + : throw new InvalidOperationException($"Environment variable '{name}' must be a positive integer."); +} + +static DemoCommand ParseCommand(string[] args, string defaultHelloInput) +{ + if (args.Length == 0) + { + return DemoCommand.Hello(defaultHelloInput); + } + + string verb = args[0].ToLowerInvariant(); + return verb switch + { + "hello" => DemoCommand.Hello(args.Length > 1 ? args[1] : defaultHelloInput), + "serve" or "http" or "api" => DemoCommand.Serve, + _ => throw new InvalidOperationException("Supported commands: hello [name], serve."), + }; +} + +internal enum DemoCommandKind +{ + Hello, + Serve, +} + +internal sealed record DemoCommand(DemoCommandKind Kind, string HelloInput) +{ + public static DemoCommand Serve { get; } = new(DemoCommandKind.Serve, string.Empty); + + public static DemoCommand Hello(string input) => new(DemoCommandKind.Hello, input); +} diff --git a/samples/serverless/declarer/ServerlessSandboxHttpHost.cs b/samples/serverless/main-app/ServerlessSandboxHttpHost.cs similarity index 89% rename from samples/serverless/declarer/ServerlessSandboxHttpHost.cs rename to samples/serverless/main-app/ServerlessSandboxHttpHost.cs index d4bb92e7..8173b2f1 100644 --- a/samples/serverless/declarer/ServerlessSandboxHttpHost.cs +++ b/samples/serverless/main-app/ServerlessSandboxHttpHost.cs @@ -8,19 +8,18 @@ using Microsoft.DurableTask.Client.AzureManaged; using Microsoft.Extensions.DependencyInjection; -namespace Microsoft.DurableTask.Samples.Serverless.Declarer; +namespace Microsoft.DurableTask.Samples.Serverless.MainApp; internal static class ServerlessSandboxHttpHost { public static async Task RunAsync( string endpoint, string taskHub, + string workerProfileId, TokenCredential credential) { WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.Services.AddSingleton(new ServerlessSandboxHttpOptions( - taskHub, - Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID") ?? "default")); + builder.Services.AddSingleton(new ServerlessSandboxHttpOptions(taskHub, workerProfileId)); builder.Services.AddDurableTaskClient(clientBuilder => { clientBuilder.UseDurableTaskScheduler(options => diff --git a/samples/serverless/declarer/ServerlessSandboxModels.cs b/samples/serverless/main-app/ServerlessSandboxModels.cs similarity index 83% rename from samples/serverless/declarer/ServerlessSandboxModels.cs rename to samples/serverless/main-app/ServerlessSandboxModels.cs index a74309ef..1787c2d5 100644 --- a/samples/serverless/declarer/ServerlessSandboxModels.cs +++ b/samples/serverless/main-app/ServerlessSandboxModels.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace Microsoft.DurableTask.Samples.Serverless.Declarer; +namespace Microsoft.DurableTask.Samples.Serverless.MainApp; public sealed record ServerlessSandboxHttpOptions( string TaskHub, @@ -16,4 +16,4 @@ public sealed record ServerlessSandboxSummary( string DtsSandboxIdentifier, string WorkerProfileId, string State, - DateTimeOffset? CreatedAt); \ No newline at end of file + DateTimeOffset? CreatedAt); diff --git a/samples/serverless/declarer/ServerlessSandboxesController.cs b/samples/serverless/main-app/ServerlessSandboxesController.cs similarity index 98% rename from samples/serverless/declarer/ServerlessSandboxesController.cs rename to samples/serverless/main-app/ServerlessSandboxesController.cs index c14cfc4e..692756dc 100644 --- a/samples/serverless/declarer/ServerlessSandboxesController.cs +++ b/samples/serverless/main-app/ServerlessSandboxesController.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.DurableTask.Client.AzureManaged; -namespace Microsoft.DurableTask.Samples.Serverless.Declarer; +namespace Microsoft.DurableTask.Samples.Serverless.MainApp; [ApiController] [Route("")] @@ -115,4 +115,4 @@ static string FormatLogLine(ServerlessSandboxLogLine line) string tag = string.IsNullOrWhiteSpace(line.Tag) ? string.Empty : $"[{line.Tag}] "; return $"{timestamp} {stream}: {tag}{line.Message}".Trim(); } -} \ No newline at end of file +} diff --git a/samples/serverless/declarer/declarer.csproj b/samples/serverless/main-app/main-app.csproj similarity index 86% rename from samples/serverless/declarer/declarer.csproj rename to samples/serverless/main-app/main-app.csproj index 65647d91..d9025d96 100644 --- a/samples/serverless/declarer/declarer.csproj +++ b/samples/serverless/main-app/main-app.csproj @@ -4,6 +4,8 @@ Exe net10.0 enable + ServerlessMainApp + Microsoft.DurableTask.Samples.Serverless.MainApp diff --git a/samples/serverless/remote-worker/Activities.cs b/samples/serverless/remote-worker/Activities.cs index 84e5977c..51a576c1 100644 --- a/samples/serverless/remote-worker/Activities.cs +++ b/samples/serverless/remote-worker/Activities.cs @@ -1,59 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Security.Cryptography; -using System.Text; using Microsoft.DurableTask; namespace Microsoft.DurableTask.Samples.Serverless.RemoteWorker; -public sealed record BurstMegaResult(int Index, int Value, string Host, int Pid); - -public sealed record ResizeImageRequest(string SourceUri, int Width, int Height); - -public sealed record ResizeImageResult(string SourceUri, int Width, int Height, string ThumbnailBase64, int SourceFingerprintLength); - [DurableTask("RemoteHello")] internal sealed class RemoteHelloActivity : TaskActivity { public override Task RunAsync(TaskActivityContext context, string input) => Task.FromResult($"hello from {Environment.MachineName} pid={Environment.ProcessId}: {input}"); } - -[DurableTask("BurstWork")] -internal sealed class BurstWorkActivity : TaskActivity -{ - public override async Task RunAsync(TaskActivityContext context, int input) - { - await Task.Delay(TimeSpan.FromSeconds(2)); - return input * 2; - } -} - -[DurableTask("BurstMegaWork")] -internal sealed class BurstMegaWorkActivity : TaskActivity -{ - public override async Task RunAsync(TaskActivityContext context, int input) - { - int durationSeconds = 30; - if (int.TryParse(Environment.GetEnvironmentVariable("DEMO_BURSTMEGA_DURATION_SECONDS"), out int parsed) && parsed > 0) - { - durationSeconds = parsed; - } - - await Task.Delay(TimeSpan.FromSeconds(durationSeconds)); - return new BurstMegaResult(input, input * 2, Environment.MachineName, Environment.ProcessId); - } -} - -[DurableTask("ResizeImage")] -internal sealed class ResizeImageActivity : TaskActivity -{ - public override Task RunAsync(TaskActivityContext context, ResizeImageRequest input) - { - byte[] fingerprint = SHA256.HashData(Encoding.UTF8.GetBytes($"{input.SourceUri}|{input.Width}|{input.Height}")); - string thumbnail = Convert.ToBase64String(fingerprint[..24]); - ResizeImageResult result = new(input.SourceUri, input.Width, input.Height, thumbnail, fingerprint.Length); - return Task.FromResult(result); - } -} diff --git a/samples/serverless/remote-worker/Program.cs b/samples/serverless/remote-worker/Program.cs index d29fbc83..5389a5b1 100644 --- a/samples/serverless/remote-worker/Program.cs +++ b/samples/serverless/remote-worker/Program.cs @@ -26,9 +26,6 @@ workerBuilder.AddTasks(tasks => { tasks.AddActivity(); - tasks.AddActivity(); - tasks.AddActivity(); - tasks.AddActivity(); }); workerBuilder.UseDurableTaskScheduler(options => { diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs index a3b85841..ca51abc7 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs @@ -245,7 +245,7 @@ async Task RunRegistrationSessionAsync( if (ReferenceEquals(completedTask, completionTask)) { - await heartbeatCts.CancelAsync().ConfigureAwait(false); + heartbeatCts.Cancel(); try { await heartbeatTask.ConfigureAwait(false); From 2ca968144c7c0119178c777e7e653cc8db94dd82 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 21 May 2026 08:58:30 -0700 Subject: [PATCH 21/81] Simplify serverless sample dashboard flow --- samples/serverless/README.md | 15 +-- samples/serverless/main-app/Program.cs | 39 ++---- .../main-app/ServerlessSandboxHttpHost.cs | 50 -------- .../main-app/ServerlessSandboxModels.cs | 19 --- .../main-app/ServerlessSandboxesController.cs | 118 ------------------ samples/serverless/main-app/main-app.csproj | 2 +- 6 files changed, 11 insertions(+), 232 deletions(-) delete mode 100644 samples/serverless/main-app/ServerlessSandboxHttpHost.cs delete mode 100644 samples/serverless/main-app/ServerlessSandboxModels.cs delete mode 100644 samples/serverless/main-app/ServerlessSandboxesController.cs diff --git a/samples/serverless/README.md b/samples/serverless/README.md index a47ef90c..8faff0e2 100644 --- a/samples/serverless/README.md +++ b/samples/serverless/README.md @@ -6,7 +6,7 @@ The sample is intentionally split into two projects: | Path | Purpose | | --- | --- | -| `main-app/` | Runs locally or in a normal app host. It declares the serverless activity, starts one hello orchestration, and can expose HTTP helpers for listing sandboxes and streaming logs. | +| `main-app/` | Runs locally or in a normal app host. It declares the serverless activity and starts one hello orchestration. | | `remote-worker/` | Builds the container image that DTS starts inside a serverless sandbox. It contains the remote hello activity. | ## Build @@ -49,16 +49,5 @@ Runtime status: Completed Output: "hello from pid=: serverless-sample" ``` -## Sandbox helper API +Use the Durable Task Scheduler dashboard's Serverless Activities preview tab to inspect serverless activity runtimes and stream runtime logs. -The main app can also expose a small HTTP helper API. The helper reuses the SDK's DTS serverless client registration instead of setting up gRPC channels directly. - -```powershell -dotnet run --project .\samples\serverless\main-app\main-app.csproj -- serve -``` - -Endpoints: - -- `GET /health` -- `GET /serverless/sandboxes?workerProfileId=default` -- `GET /serverless/sandboxes/{dtsSandboxIdentifier}/logs?tail=100` diff --git a/samples/serverless/main-app/Program.cs b/samples/serverless/main-app/Program.cs index e1704e58..84da2345 100644 --- a/samples/serverless/main-app/Program.cs +++ b/samples/serverless/main-app/Program.cs @@ -20,19 +20,10 @@ string workerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID") ?? "default"; string serverlessActivityImage = Environment.GetEnvironmentVariable("DTS_SERVERLESS_ACTIVITY_IMAGE") ?? "serverless-remote-worker:local"; -string helloInput = Environment.GetEnvironmentVariable("DTS_SAMPLE_HELLO_INPUT") ?? "serverless-sample"; +string helloInput = ParseHelloInput( + args, + Environment.GetEnvironmentVariable("DTS_SAMPLE_HELLO_INPUT") ?? "serverless-sample"); TokenCredential credential = new DefaultAzureCredential(); -DemoCommand command = ParseCommand(args, helloInput); - -if (command.Kind == DemoCommandKind.Serve) -{ - await ServerlessSandboxHttpHost.RunAsync( - endpoint, - taskHub, - workerProfileId, - credential); - return; -} HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); builder.Logging.AddSimpleConsole(options => @@ -82,7 +73,7 @@ await ServerlessSandboxHttpHost.RunAsync( DurableTaskClient client = host.Services.GetRequiredService(); string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( ServerlessTaskNames.HelloOrchestrator, - input: command.HelloInput); + input: helloInput); OrchestrationMetadata? result = await client.WaitForInstanceCompletionAsync( instanceId, getInputsAndOutputs: true); @@ -110,31 +101,17 @@ static int GetIntEnv(string name, int defaultValue) : throw new InvalidOperationException($"Environment variable '{name}' must be a positive integer."); } -static DemoCommand ParseCommand(string[] args, string defaultHelloInput) +static string ParseHelloInput(string[] args, string defaultHelloInput) { if (args.Length == 0) { - return DemoCommand.Hello(defaultHelloInput); + return defaultHelloInput; } string verb = args[0].ToLowerInvariant(); return verb switch { - "hello" => DemoCommand.Hello(args.Length > 1 ? args[1] : defaultHelloInput), - "serve" or "http" or "api" => DemoCommand.Serve, - _ => throw new InvalidOperationException("Supported commands: hello [name], serve."), + "hello" => args.Length > 1 ? args[1] : defaultHelloInput, + _ => throw new InvalidOperationException("Supported commands: hello [name]."), }; } - -internal enum DemoCommandKind -{ - Hello, - Serve, -} - -internal sealed record DemoCommand(DemoCommandKind Kind, string HelloInput) -{ - public static DemoCommand Serve { get; } = new(DemoCommandKind.Serve, string.Empty); - - public static DemoCommand Hello(string input) => new(DemoCommandKind.Hello, input); -} diff --git a/samples/serverless/main-app/ServerlessSandboxHttpHost.cs b/samples/serverless/main-app/ServerlessSandboxHttpHost.cs deleted file mode 100644 index 8173b2f1..00000000 --- a/samples/serverless/main-app/ServerlessSandboxHttpHost.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Azure.Core; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.DurableTask.Client; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.Extensions.DependencyInjection; - -namespace Microsoft.DurableTask.Samples.Serverless.MainApp; - -internal static class ServerlessSandboxHttpHost -{ - public static async Task RunAsync( - string endpoint, - string taskHub, - string workerProfileId, - TokenCredential credential) - { - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.Services.AddSingleton(new ServerlessSandboxHttpOptions(taskHub, workerProfileId)); - builder.Services.AddDurableTaskClient(clientBuilder => - { - clientBuilder.UseDurableTaskScheduler(options => - { - options.EndpointAddress = endpoint; - options.TaskHubName = taskHub; - options.Credential = credential; - }); - }); - builder.Services.AddDurableTaskSchedulerServerlessActivitiesClient(); - builder.Services.AddControllers(); - - string? urls = Environment.GetEnvironmentVariable("DTS_DEMO_HTTP_URLS") - ?? Environment.GetEnvironmentVariable("ASPNETCORE_URLS"); - if (string.IsNullOrWhiteSpace(urls)) - { - builder.WebHost.UseUrls("http://localhost:5188"); - } - else - { - builder.WebHost.UseUrls(urls); - } - - WebApplication app = builder.Build(); - app.MapControllers(); - await app.RunAsync(); - } -} diff --git a/samples/serverless/main-app/ServerlessSandboxModels.cs b/samples/serverless/main-app/ServerlessSandboxModels.cs deleted file mode 100644 index 1787c2d5..00000000 --- a/samples/serverless/main-app/ServerlessSandboxModels.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Microsoft.DurableTask.Samples.Serverless.MainApp; - -public sealed record ServerlessSandboxHttpOptions( - string TaskHub, - string DefaultWorkerProfileId); - -public sealed record ServerlessSandboxListResponse( - string TaskHub, - string WorkerProfileId, - IReadOnlyList Sandboxes); - -public sealed record ServerlessSandboxSummary( - string DtsSandboxIdentifier, - string WorkerProfileId, - string State, - DateTimeOffset? CreatedAt); diff --git a/samples/serverless/main-app/ServerlessSandboxesController.cs b/samples/serverless/main-app/ServerlessSandboxesController.cs deleted file mode 100644 index 692756dc..00000000 --- a/samples/serverless/main-app/ServerlessSandboxesController.cs +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Grpc.Core; -using Microsoft.AspNetCore.Mvc; -using Microsoft.DurableTask.Client.AzureManaged; - -namespace Microsoft.DurableTask.Samples.Serverless.MainApp; - -[ApiController] -[Route("")] -public sealed class HealthController : ControllerBase -{ - [HttpGet("health")] - public ActionResult GetHealth() => this.Ok(new { status = "ok" }); -} - -[ApiController] -[Route("serverless/sandboxes")] -public sealed class ServerlessSandboxesController( - ServerlessActivitiesClient client, - ServerlessSandboxHttpOptions options) : ControllerBase -{ - readonly ServerlessActivitiesClient client = client; - readonly ServerlessSandboxHttpOptions options = options; - - [HttpGet] - public async Task> ListSandboxes( - [FromQuery] string? workerProfileId, - CancellationToken cancellationToken) - { - try - { - string resolvedWorkerProfileId = string.IsNullOrWhiteSpace(workerProfileId) - ? this.options.DefaultWorkerProfileId - : workerProfileId; - IReadOnlyList sandboxes = await this.client.ListServerlessActivitySandboxesAsync( - resolvedWorkerProfileId, - cancellationToken); - ServerlessSandboxListResponse response = new( - this.options.TaskHub, - resolvedWorkerProfileId, - sandboxes.Select(sandbox => new ServerlessSandboxSummary( - sandbox.DtsSandboxIdentifier, - sandbox.WorkerProfileId, - sandbox.State, - sandbox.CreatedAt == default ? null : sandbox.CreatedAt)) - .ToArray()); - - return this.Ok(response); - } - catch (RpcException ex) - { - return ToGrpcProblem(ex); - } - catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) - { - return this.Problem(ex.Message, statusCode: StatusCodes.Status400BadRequest); - } - } - - [HttpGet("{dtsSandboxIdentifier}/logs")] - public async Task StreamLogs( - [FromRoute] string dtsSandboxIdentifier, - [FromQuery] int? tail, - CancellationToken cancellationToken) - { - this.Response.ContentType = "text/plain; charset=utf-8"; - try - { - int resolvedTail = Math.Clamp(tail ?? 100, 0, 300); - await foreach (ServerlessSandboxLogLine line in this.client.StreamSandboxLogsAsync( - dtsSandboxIdentifier, - resolvedTail, - cancellationToken)) - { - await this.Response.WriteAsync(FormatLogLine(line), cancellationToken); - await this.Response.WriteAsync(Environment.NewLine, cancellationToken); - await this.Response.Body.FlushAsync(cancellationToken); - } - } - catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.Cancelled) - { - } - catch (OperationCanceledException) - { - } - catch (RpcException ex) when (!this.Response.HasStarted) - { - this.Response.StatusCode = StatusCodes.Status502BadGateway; - await this.Response.WriteAsync($"DTS serverless log stream failed: {ex.Status.Detail}", cancellationToken); - } - catch (Exception ex) when ((ex is ArgumentException or InvalidOperationException) && !this.Response.HasStarted) - { - this.Response.StatusCode = StatusCodes.Status400BadRequest; - await this.Response.WriteAsync(ex.Message, cancellationToken); - } - } - - ActionResult ToGrpcProblem(RpcException ex) - => this.Problem( - detail: ex.Status.Detail, - statusCode: StatusCodes.Status502BadGateway, - title: "DTS serverless gRPC call failed"); - - static string FormatLogLine(ServerlessSandboxLogLine line) - { - if (!string.IsNullOrWhiteSpace(line.RawLine)) - { - return line.RawLine; - } - - string timestamp = line.Timestamp == default ? string.Empty : line.Timestamp.ToString("O"); - string stream = string.IsNullOrWhiteSpace(line.Stream) ? "log" : line.Stream; - string tag = string.IsNullOrWhiteSpace(line.Tag) ? string.Empty : $"[{line.Tag}] "; - return $"{timestamp} {stream}: {tag}{line.Message}".Trim(); - } -} diff --git a/samples/serverless/main-app/main-app.csproj b/samples/serverless/main-app/main-app.csproj index d9025d96..d73515dc 100644 --- a/samples/serverless/main-app/main-app.csproj +++ b/samples/serverless/main-app/main-app.csproj @@ -1,4 +1,4 @@ - + Exe From dfede69cee5b42fc659806808574bffbd656c4ac Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 21 May 2026 16:08:03 -0700 Subject: [PATCH 22/81] cleanup --- .../Client/ServerlessActivitiesClient.cs | 25 --- .../ServerlessActivitiesClientExtensions.cs | 129 ----------- .../Client/ServerlessSandboxInfo.cs | 17 -- .../Client/ServerlessSandboxLogLine.cs | 21 -- src/Grpc/serverless_activities_service.proto | 37 --- ...rverlessActivitiesClientExtensionsTests.cs | 212 +----------------- 6 files changed, 5 insertions(+), 436 deletions(-) delete mode 100644 src/Extensions/AzureManagedServerless/Client/ServerlessSandboxInfo.cs delete mode 100644 src/Extensions/AzureManagedServerless/Client/ServerlessSandboxLogLine.cs diff --git a/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClient.cs b/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClient.cs index 8801d01b..01f32ae7 100644 --- a/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClient.cs +++ b/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClient.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Collections.Generic; using Proto = Microsoft.DurableTask.Protobuf.Serverless; namespace Microsoft.DurableTask.Client.AzureManaged; @@ -22,17 +21,6 @@ internal ServerlessActivitiesClient(Proto.ServerlessActivities.ServerlessActivit this.client = client; } - /// - /// Lists DTS-managed sandboxes for a serverless activity worker profile. - /// - /// The worker profile ID to list sandboxes for. - /// The cancellation token used to cancel the request. - /// The sandboxes currently known to DTS for the worker profile. - public Task> ListServerlessActivitySandboxesAsync( - string workerProfileId, - CancellationToken cancellation = default) - => this.client.ListServerlessActivitySandboxesAsync(workerProfileId, cancellation); - /// /// Removes a serverless activity declaration for a worker profile. /// @@ -43,17 +31,4 @@ public Task RemoveServerlessActivityDeclarationAsync( string workerProfileId, CancellationToken cancellation = default) => this.client.RemoveServerlessActivityDeclarationAsync(workerProfileId, cancellation); - - /// - /// Streams logs from a serverless activity sandbox. - /// - /// The DTS sandbox identifier to stream logs from. - /// The number of historical log lines to include before streaming live logs. - /// The cancellation token used to stop streaming. - /// An async stream of sandbox log lines. - public IAsyncEnumerable StreamSandboxLogsAsync( - string dtsSandboxIdentifier, - int tail = 100, - CancellationToken cancellation = default) - => this.client.StreamSandboxLogsAsync(dtsSandboxIdentifier, tail, cancellation); } diff --git a/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs b/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs index 892b1985..05de3530 100644 --- a/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs +++ b/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs @@ -1,9 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using Google.Protobuf.WellKnownTypes; using Grpc.Core; using Proto = Microsoft.DurableTask.Protobuf.Serverless; @@ -14,27 +11,6 @@ namespace Microsoft.DurableTask.Client.AzureManaged; /// public static class ServerlessActivitiesClientExtensions { - const int MinTail = 0; - const int MaxTail = 300; - - /// - /// Lists DTS-managed sandboxes for a serverless activity worker profile using task hub metadata already configured on the gRPC channel. - /// - /// The generated serverless activities gRPC client. - /// The worker profile ID to list sandboxes for. - /// The cancellation token used to cancel the request. - /// The sandboxes currently known to DTS for the worker profile. - public static Task> ListServerlessActivitySandboxesAsync( - this Proto.ServerlessActivities.ServerlessActivitiesClient client, - string workerProfileId, - CancellationToken cancellation = default) - { - return ListServerlessActivitySandboxesCoreAsync( - client, - workerProfileId, - cancellation); - } - /// /// Removes a serverless activity declaration for a worker profile using task hub metadata already configured on the gRPC channel. /// @@ -53,55 +29,6 @@ public static Task RemoveServerlessActivityDeclarationAsync( cancellation); } - /// - /// Streams logs from a serverless activity sandbox using task hub metadata already configured on the gRPC channel. - /// - /// The generated serverless activities gRPC client. - /// The DTS sandbox identifier to stream logs from. - /// The number of historical log lines to include before streaming live logs. Must be between 0 and 300. - /// The cancellation token used to stop streaming. - /// An async stream of sandbox log lines. - public static IAsyncEnumerable StreamSandboxLogsAsync( - this Proto.ServerlessActivities.ServerlessActivitiesClient client, - string dtsSandboxIdentifier, - int tail = 100, - CancellationToken cancellation = default) - { - return StreamSandboxLogsCoreAsync( - client, - dtsSandboxIdentifier, - tail, - cancellation); - } - - static async Task> ListServerlessActivitySandboxesCoreAsync( - Proto.ServerlessActivities.ServerlessActivitiesClient client, - string workerProfileId, - CancellationToken cancellation) - { - ArgumentNullException.ThrowIfNull(client); - ValidateRequired(workerProfileId, nameof(workerProfileId), "Worker profile ID is required."); - - Proto.ListServerlessActivitySandboxesRequest request = new() - { - WorkerProfileId = workerProfileId, - }; - - using AsyncUnaryCall call = client.ListServerlessActivitySandboxesAsync( - request, - headers: null, - cancellationToken: cancellation); - Proto.ListServerlessActivitySandboxesResult result = await call.ResponseAsync.ConfigureAwait(false); - - List sandboxes = new(result.Sandboxes.Count); - foreach (Proto.ServerlessActivitySandbox sandbox in result.Sandboxes) - { - sandboxes.Add(FromProto(sandbox)); - } - - return sandboxes; - } - static async Task RemoveServerlessActivityDeclarationCoreAsync( Proto.ServerlessActivities.ServerlessActivitiesClient client, string workerProfileId, @@ -122,48 +49,6 @@ static async Task RemoveServerlessActivityDeclarationCoreAsync( await call.ResponseAsync.ConfigureAwait(false); } - static async IAsyncEnumerable StreamSandboxLogsCoreAsync( - Proto.ServerlessActivities.ServerlessActivitiesClient client, - string dtsSandboxIdentifier, - int tail, - [EnumeratorCancellation] CancellationToken cancellation) - { - ArgumentNullException.ThrowIfNull(client); - ValidateRequest(dtsSandboxIdentifier, tail); - - Proto.SandboxLogStreamRequest request = new() - { - DtsSandboxIdentifier = dtsSandboxIdentifier, - Tail = tail, - }; - - using AsyncServerStreamingCall call = client.StreamSandboxLogs( - request, - headers: null, - cancellationToken: cancellation); - - while (await call.ResponseStream.MoveNext(cancellation).ConfigureAwait(false)) - { - yield return FromProto(call.ResponseStream.Current); - } - } - - static void ValidateRequest(string dtsSandboxIdentifier, int tail) - { - ValidateRequired( - dtsSandboxIdentifier, - nameof(dtsSandboxIdentifier), - "DTS sandbox identifier is required."); - - if (tail < MinTail || tail > MaxTail) - { - throw new ArgumentOutOfRangeException( - nameof(tail), - tail, - $"Tail must be between {MinTail} and {MaxTail}."); - } - } - static void ValidateRequired(string value, string parameterName, string message) { if (string.IsNullOrWhiteSpace(value)) @@ -171,18 +56,4 @@ static void ValidateRequired(string value, string parameterName, string message) throw new ArgumentException(message, parameterName); } } - - static ServerlessSandboxInfo FromProto(Proto.ServerlessActivitySandbox sandbox) => new( - sandbox.DtsSandboxIdentifier, - sandbox.WorkerProfileId, - sandbox.CreatedAt?.ToDateTimeOffset() ?? default, - sandbox.State); - - static ServerlessSandboxLogLine FromProto(Proto.SandboxLogLine line) => new( - line.DtsSandboxIdentifier, - line.Timestamp?.ToDateTimeOffset() ?? default, - line.Stream, - line.Tag, - line.Message, - line.RawLine); } diff --git a/src/Extensions/AzureManagedServerless/Client/ServerlessSandboxInfo.cs b/src/Extensions/AzureManagedServerless/Client/ServerlessSandboxInfo.cs deleted file mode 100644 index b9aa6cde..00000000 --- a/src/Extensions/AzureManagedServerless/Client/ServerlessSandboxInfo.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Microsoft.DurableTask.Client.AzureManaged; - -/// -/// A DTS-managed sandbox that can execute serverless activities for a worker profile. -/// -/// The DTS-generated sandbox identifier injected into the worker as DTS_SANDBOX_ID. -/// The worker profile associated with the sandbox. -/// The time when the sandbox was created. -/// The current sandbox state reported by DTS. -public sealed record ServerlessSandboxInfo( - string DtsSandboxIdentifier, - string WorkerProfileId, - DateTimeOffset CreatedAt, - string State); diff --git a/src/Extensions/AzureManagedServerless/Client/ServerlessSandboxLogLine.cs b/src/Extensions/AzureManagedServerless/Client/ServerlessSandboxLogLine.cs deleted file mode 100644 index f7dbd7cb..00000000 --- a/src/Extensions/AzureManagedServerless/Client/ServerlessSandboxLogLine.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Microsoft.DurableTask.Client.AzureManaged; - -/// -/// A log line emitted by a serverless activity sandbox. -/// -/// The DTS sandbox identifier that produced the log line. -/// The timestamp associated with the log line. -/// The output stream that produced the line, such as stdout or stderr. -/// The log tag reported by the sandbox runtime. -/// The parsed log message. -/// The original log line. -public sealed record ServerlessSandboxLogLine( - string DtsSandboxIdentifier, - DateTimeOffset Timestamp, - string Stream, - string Tag, - string Message, - string RawLine); diff --git a/src/Grpc/serverless_activities_service.proto b/src/Grpc/serverless_activities_service.proto index a99e1d5e..4aa520d6 100644 --- a/src/Grpc/serverless_activities_service.proto +++ b/src/Grpc/serverless_activities_service.proto @@ -5,8 +5,6 @@ syntax = "proto3"; package microsoft.durabletask.serverless; -import "google/protobuf/timestamp.proto"; - option csharp_namespace = "Microsoft.DurableTask.Protobuf.Serverless"; service ServerlessActivities { @@ -23,11 +21,6 @@ service ServerlessActivities { // for the specified worker profile. Existing workers are not terminated by this RPC. rpc RemoveServerlessActivityDeclaration(RemoveServerlessActivityDeclarationRequest) returns (RemoveServerlessActivityDeclarationResult); - // Lists DTS-managed sandboxes for a declared worker profile in the current task hub. - rpc ListServerlessActivitySandboxes(ListServerlessActivitySandboxesRequest) returns (ListServerlessActivitySandboxesResult); - - // Streams best-effort stdout/stderr log lines from a DTS-managed sandbox. - rpc StreamSandboxLogs(SandboxLogStreamRequest) returns (stream SandboxLogLine); } message ServerlessActivityWorkerMessage { @@ -91,36 +84,6 @@ message RemoveServerlessActivityDeclarationRequest { message RemoveServerlessActivityDeclarationResult { } -message ListServerlessActivitySandboxesRequest { - string worker_profile_id = 1; -} - -message ListServerlessActivitySandboxesResult { - repeated ServerlessActivitySandbox sandboxes = 1; -} - -message ServerlessActivitySandbox { - string dts_sandbox_identifier = 1; - string worker_profile_id = 2; - google.protobuf.Timestamp created_at = 3; - string state = 4; -} - -message SandboxLogStreamRequest { - // DTS-generated sandbox identifier injected into the worker as DTS_SANDBOX_ID. - string dts_sandbox_identifier = 1; - int32 tail = 2; -} - -message SandboxLogLine { - string dts_sandbox_identifier = 1; - google.protobuf.Timestamp timestamp = 2; - string stream = 3; - string tag = 4; - string message = 5; - string raw_line = 6; -} - // Compute substrate executing the activity worker. enum SubstrateKind { SUBSTRATE_KIND_UNSPECIFIED = 0; diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs index f742c15d..9db1c9ad 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using FluentAssertions; -using Google.Protobuf.WellKnownTypes; using Grpc.Core; using Microsoft.DurableTask.Client.Grpc; using Microsoft.DurableTask.Protobuf.Serverless; @@ -14,60 +13,11 @@ namespace Microsoft.DurableTask.Client.AzureManaged.Tests; public class ServerlessActivitiesClientExtensionsTests { - [Fact] - public async Task ListServerlessActivitySandboxesAsync_SendsRequestAndMapsSandboxes() - { - // Arrange - DateTimeOffset createdAt = new(2026, 5, 14, 10, 30, 0, TimeSpan.Zero); - RecordingServerlessLogCallInvoker callInvoker = new( - new ListServerlessActivitySandboxesResult - { - Sandboxes = - { - new ServerlessActivitySandbox - { - DtsSandboxIdentifier = "sandbox-1", - WorkerProfileId = "default", - CreatedAt = createdAt.ToTimestamp(), - State = "Running", - }, - }, - }); - ServerlessActivities.ServerlessActivitiesClient client = new(callInvoker); - - // Act - IReadOnlyList sandboxes = await client.ListServerlessActivitySandboxesAsync("default"); - - // Assert - callInvoker.ListRequest.Should().NotBeNull(); - callInvoker.ListRequest!.WorkerProfileId.Should().Be("default"); - callInvoker.ListHeaders.Should().NotContain(header => header.Key == "taskhub"); - callInvoker.UnaryDisposeCount.Should().Be(1); - - ServerlessSandboxInfo mapped = sandboxes.Should().ContainSingle().Subject; - mapped.DtsSandboxIdentifier.Should().Be("sandbox-1"); - mapped.WorkerProfileId.Should().Be("default"); - mapped.CreatedAt.Should().Be(createdAt); - mapped.State.Should().Be("Running"); - } - [Fact] public async Task AddDurableTaskSchedulerServerlessActivitiesClient_UsesConfiguredDurableTaskClientInvoker() { // Arrange - RecordingServerlessLogCallInvoker callInvoker = new( - new ListServerlessActivitySandboxesResult - { - Sandboxes = - { - new ServerlessActivitySandbox - { - DtsSandboxIdentifier = "sandbox-1", - WorkerProfileId = "default", - State = "Running", - }, - }, - }); + RecordingServerlessLogCallInvoker callInvoker = new(); ServiceCollection services = new(); services.AddOptions(Options.DefaultName) .Configure(options => options.CallInvoker = callInvoker); @@ -77,12 +27,11 @@ public async Task AddDurableTaskSchedulerServerlessActivitiesClient_UsesConfigur ServerlessActivitiesClient client = provider.GetRequiredService(); // Act - IReadOnlyList sandboxes = await client.ListServerlessActivitySandboxesAsync("default"); + await client.RemoveServerlessActivityDeclarationAsync("default"); // Assert - callInvoker.ListRequest.Should().NotBeNull(); - callInvoker.ListRequest!.WorkerProfileId.Should().Be("default"); - sandboxes.Should().ContainSingle().Which.DtsSandboxIdentifier.Should().Be("sandbox-1"); + callInvoker.RemoveRequest.Should().NotBeNull(); + callInvoker.RemoveRequest!.WorkerProfileId.Should().Be("default"); } [Fact] @@ -102,114 +51,8 @@ public async Task RemoveServerlessActivityDeclarationAsync_SendsRequest() callInvoker.UnaryDisposeCount.Should().Be(1); } - [Fact] - public async Task StreamSandboxLogsAsync_SendsRequestAndMapsLines() - { - // Arrange - DateTimeOffset timestamp = new(2026, 5, 14, 10, 30, 0, TimeSpan.Zero); - RecordingServerlessLogCallInvoker callInvoker = new( - new SandboxLogLine - { - DtsSandboxIdentifier = "sandbox-1", - Timestamp = timestamp.ToTimestamp(), - Stream = "stdout", - Tag = "worker", - Message = "hello from serverless", - RawLine = "2026-05-14T10:30:00Z stdout worker hello from serverless", - }); - ServerlessActivities.ServerlessActivitiesClient client = new(callInvoker); - - // Act - List lines = []; - await foreach (ServerlessSandboxLogLine line in client.StreamSandboxLogsAsync( - "sandbox-1", - tail: 42)) - { - lines.Add(line); - } - - // Assert - callInvoker.Request.Should().NotBeNull(); - callInvoker.Request!.DtsSandboxIdentifier.Should().Be("sandbox-1"); - callInvoker.Request.Tail.Should().Be(42); - callInvoker.Headers.Should().NotContain(header => header.Key == "taskhub"); - callInvoker.DisposeCount.Should().Be(1); - - ServerlessSandboxLogLine mapped = lines.Should().ContainSingle().Subject; - mapped.DtsSandboxIdentifier.Should().Be("sandbox-1"); - mapped.Timestamp.Should().Be(timestamp); - mapped.Stream.Should().Be("stdout"); - mapped.Tag.Should().Be("worker"); - mapped.Message.Should().Be("hello from serverless"); - mapped.RawLine.Should().Be("2026-05-14T10:30:00Z stdout worker hello from serverless"); - } - - [Fact] - public async Task StreamSandboxLogsAsync_DoesNotAttachTaskHubMetadata() - { - // Arrange - RecordingServerlessLogCallInvoker callInvoker = new(); - ServerlessActivities.ServerlessActivitiesClient client = new(callInvoker); - - // Act - await foreach (ServerlessSandboxLogLine _ in client.StreamSandboxLogsAsync("sandbox-1", tail: 42)) - { - } - - // Assert - callInvoker.Headers.Should().NotContain(header => header.Key == "taskhub"); - } - - [Theory] - [InlineData(-1)] - [InlineData(301)] - public async Task StreamSandboxLogsAsync_WithInvalidTail_ThrowsArgumentOutOfRangeException(int tail) - { - // Arrange - ServerlessActivities.ServerlessActivitiesClient client = new(new RecordingServerlessLogCallInvoker()); - - // Act - Func action = async () => - { - await foreach (ServerlessSandboxLogLine _ in client.StreamSandboxLogsAsync( - "sandbox-1", - tail: tail)) - { - } - }; - - // Assert - await action.Should().ThrowAsync() - .WithParameterName("tail"); - } - sealed class RecordingServerlessLogCallInvoker : CallInvoker { - readonly SandboxLogStreamReader responseStream; - readonly ListServerlessActivitySandboxesResult listResponse; - - public RecordingServerlessLogCallInvoker(params SandboxLogLine[] lines) - { - this.responseStream = new SandboxLogStreamReader(lines); - this.listResponse = new ListServerlessActivitySandboxesResult(); - } - - public RecordingServerlessLogCallInvoker(ListServerlessActivitySandboxesResult listResponse) - { - this.responseStream = new SandboxLogStreamReader([]); - this.listResponse = listResponse; - } - - public SandboxLogStreamRequest? Request { get; private set; } - - public Metadata Headers { get; private set; } = []; - - public int DisposeCount { get; private set; } - - public ListServerlessActivitySandboxesRequest? ListRequest { get; private set; } - - public Metadata ListHeaders { get; private set; } = []; - public RemoveServerlessActivityDeclarationRequest? RemoveRequest { get; private set; } public Metadata RemoveHeaders { get; private set; } = []; @@ -231,19 +74,6 @@ public override AsyncUnaryCall AsyncUnaryCall( CallOptions options, TRequest request) { - if (method.FullName.EndsWith("/ListServerlessActivitySandboxes", StringComparison.Ordinal)) - { - this.ListRequest = (ListServerlessActivitySandboxesRequest)(object)request; - this.ListHeaders = options.Headers ?? []; - - return new AsyncUnaryCall( - Task.FromResult((TResponse)(object)this.listResponse), - Task.FromResult(new Metadata()), - () => new Status(StatusCode.OK, string.Empty), - () => new Metadata(), - () => this.UnaryDisposeCount++); - } - method.FullName.Should().EndWith("/RemoveServerlessActivityDeclaration"); this.RemoveRequest = (RemoveServerlessActivityDeclarationRequest)(object)request; this.RemoveHeaders = options.Headers ?? []; @@ -262,16 +92,7 @@ public override AsyncServerStreamingCall AsyncServerStreamingCall( - (IAsyncStreamReader)(object)this.responseStream, - Task.FromResult(new Metadata()), - () => new Status(StatusCode.OK, string.Empty), - () => new Metadata(), - () => this.DisposeCount++); + throw new NotSupportedException(); } public override AsyncClientStreamingCall AsyncClientStreamingCall( @@ -290,27 +111,4 @@ public override AsyncDuplexStreamingCall AsyncDuplexStreami throw new NotSupportedException(); } } - - sealed class SandboxLogStreamReader : IAsyncStreamReader - { - readonly Queue lines; - - public SandboxLogStreamReader(IEnumerable lines) - { - this.lines = new Queue(lines); - } - - public SandboxLogLine Current { get; private set; } = new(); - - public Task MoveNext(CancellationToken cancellationToken) - { - if (this.lines.Count == 0) - { - return Task.FromResult(false); - } - - this.Current = this.lines.Dequeue(); - return Task.FromResult(true); - } - } } From 81a603cc2ddff7d32872b68080f629423c9e539b Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 21 May 2026 16:19:15 -0700 Subject: [PATCH 23/81] sync proto --- src/Grpc/serverless_activities_service.proto | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Grpc/serverless_activities_service.proto b/src/Grpc/serverless_activities_service.proto index 4aa520d6..184006d2 100644 --- a/src/Grpc/serverless_activities_service.proto +++ b/src/Grpc/serverless_activities_service.proto @@ -20,7 +20,6 @@ service ServerlessActivities { // Removes a serverless activity declaration so the backend stops waking new workers // for the specified worker profile. Existing workers are not terminated by this RPC. rpc RemoveServerlessActivityDeclaration(RemoveServerlessActivityDeclarationRequest) returns (RemoveServerlessActivityDeclarationResult); - } message ServerlessActivityWorkerMessage { From 18c8e0209cfcc09752c4dc7b475f3a056092c720 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 21 May 2026 16:35:12 -0700 Subject: [PATCH 24/81] remove env var --- samples/serverless/remote-worker/Program.cs | 36 +++++++- ...TaskSchedulerServerlessWorkerExtensions.cs | 74 ++++------------ .../ServerlessActivityConfiguration.cs | 9 +- .../Worker/Serverless/ServerlessOptions.cs | 22 +++-- .../ServerlessActivitiesTests.cs | 86 ++++++++++--------- 5 files changed, 116 insertions(+), 111 deletions(-) diff --git a/samples/serverless/remote-worker/Program.cs b/samples/serverless/remote-worker/Program.cs index 5389a5b1..ac9a9743 100644 --- a/samples/serverless/remote-worker/Program.cs +++ b/samples/serverless/remote-worker/Program.cs @@ -12,6 +12,11 @@ string taskHub = Environment.GetEnvironmentVariable("DTS_TASK_HUB") ?? Environment.GetEnvironmentVariable("DTS_TASKHUB") ?? "ServerlessPocHub"; +string workerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID") ?? "default"; +string[] serverlessActivities = SplitEnvironmentList(Environment.GetEnvironmentVariable("DTS_SERVERLESS_ACTIVITIES")); +int maxConcurrentActivities = GetIntEnv("DTS_SERVERLESS_MAX_ACTIVITIES", 100); +string? substrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); +string? dtsSandboxIdentifier = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID"); HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); builder.Logging.AddSimpleConsole(options => @@ -32,7 +37,18 @@ options.EndpointAddress = endpoint; options.TaskHubName = taskHub; }); - workerBuilder.UseServerlessWorker(); + workerBuilder.UseServerlessWorker(options => + { + options.TaskHub = taskHub; + options.WorkerProfileId = workerProfileId; + options.MaxConcurrentActivities = maxConcurrentActivities; + options.Substrate = substrate; + options.DtsSandboxIdentifier = dtsSandboxIdentifier; + foreach (string activityName in serverlessActivities) + { + options.ActivityNames.Add(activityName); + } + }); }); await builder.Build().RunAsync(); @@ -40,3 +56,21 @@ static string GetRequiredEnvironmentVariable(string name) => Environment.GetEnvironmentVariable(name) ?? throw new InvalidOperationException($"An environment variable named '{name}' is required."); + +static string[] SplitEnvironmentList(string? value) + => string.IsNullOrWhiteSpace(value) + ? [] + : value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + +static int GetIntEnv(string name, int defaultValue) +{ + string? value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + return int.TryParse(value, out int parsed) && parsed > 0 + ? parsed + : throw new InvalidOperationException($"Environment variable '{name}' must be a positive integer."); +} diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs index c98bd203..7f5b0414 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -21,8 +21,7 @@ namespace Microsoft.DurableTask.Worker.AzureManaged; public static class DurableTaskSchedulerServerlessWorkerExtensions { /// - /// Declares serverless activities with DTS, excludes them from local execution, and propagates the - /// activity list to sandbox workers via the DTS_SERVERLESS_ACTIVITIES environment variable. + /// Declares serverless activities with DTS and excludes them from local execution. /// Call this on the local coordinator worker — not on the sandbox worker binary. /// /// The Durable Task worker builder to configure. @@ -51,7 +50,6 @@ public static IDurableTaskWorkerBuilder DeclareServerlessActivities( /// /// Configures this worker as a serverless activity worker that connects to DTS to receive and execute /// serverless activities. Use this on a dedicated worker binary that runs inside serverless infrastructure. - /// All configuration is read from environment variables injected by the backend and coordinator. /// /// /// @@ -59,25 +57,34 @@ public static IDurableTaskWorkerBuilder DeclareServerlessActivities( /// to declare and provision the serverless activity configuration. /// /// - /// Required environment variables (injected automatically by the backend and coordinator): - /// - /// DTS_SUBSTRATE — identifies the sandbox substrate (injected by backend) - /// DTS_SERVERLESS_ACTIVITIES — comma-separated activity names to execute (injected by coordinator) - /// DTS_TASK_HUB — task hub name (injected by coordinator) - /// + /// Pass any environment-derived values explicitly through the configure callback or pre-configured options. /// /// /// The Durable Task worker builder to configure. /// The original builder, for call chaining. public static IDurableTaskWorkerBuilder UseServerlessWorker(this IDurableTaskWorkerBuilder builder) + => UseServerlessWorker(builder, static _ => { }); + + /// + /// Configures this worker as a serverless activity worker that connects to DTS to receive and execute + /// serverless activities. Use this on a dedicated worker binary that runs inside serverless infrastructure. + /// + /// The Durable Task worker builder to configure. + /// Callback to configure serverless worker behavior. + /// The original builder, for call chaining. + public static IDurableTaskWorkerBuilder UseServerlessWorker( + this IDurableTaskWorkerBuilder builder, + Action configure) { Check.NotNull(builder); + Check.NotNull(configure); builder.Services.AddOptions(builder.Name) + .Configure(configure) .PostConfigure>((options, schedulerOptions) => { ApplyTaskHubDefault(options, schedulerOptions.Get(builder.Name).TaskHubName); - ApplyWorkerEnvironmentOverrides(options); + options.Mode = ServerlessMode.ServerlessInclude; }); builder.Services.AddOptions(builder.Name) @@ -197,53 +204,6 @@ static void ApplyTaskHubDefault(ServerlessOptions options, string taskHubName) } } - static void ApplyWorkerEnvironmentOverrides(ServerlessOptions options) - { - // Auto-detect worker mode from DTS_SUBSTRATE, which the backend injects when - // launching a sandbox. This is the authoritative signal that this process is a sandbox worker. - string? substrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); - if (string.Equals(substrate, "Sandbox", StringComparison.OrdinalIgnoreCase) - || string.Equals(substrate, "AcaSessionPool", StringComparison.OrdinalIgnoreCase)) - { - options.Mode = ServerlessMode.ServerlessInclude; - } - - // DTS_SERVERLESS_ACTIVITIES is injected by the coordinator into the sandbox environment. - ApplyActivityNameEnvironmentOverride(options.ActivityNames); - ApplyWorkerProfileEnvironmentOverride(profile => options.WorkerProfileId = profile); - - if (int.TryParse(Environment.GetEnvironmentVariable("DTS_SERVERLESS_MAX_ACTIVITIES"), out int maxActivities) && maxActivities > 0) - { - options.MaxConcurrentActivities = maxActivities; - } - } - - static void ApplyActivityNameEnvironmentOverride(ICollection activityNames) - { - string? serverlessActivities = Environment.GetEnvironmentVariable("DTS_SERVERLESS_ACTIVITIES"); - if (serverlessActivities is null) - { - return; - } - - activityNames.Clear(); - foreach (string name in serverlessActivities - .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) - .Distinct(StringComparer.Ordinal)) - { - activityNames.Add(name); - } - } - - static void ApplyWorkerProfileEnvironmentOverride(Action setWorkerProfileId) - { - string? workerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID"); - if (!string.IsNullOrWhiteSpace(workerProfileId)) - { - setWorkerProfileId(workerProfileId.Trim()); - } - } - static DurableTaskWorkerWorkItemFilters.ActivityFilter[] MergeActivityFilters( IReadOnlyList existingFilters, IEnumerable activityNames) diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs index 85fc0645..90361c23 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs @@ -87,8 +87,8 @@ public static Proto.ServerlessActivityWorkerMessage BuildWorkerStart(ServerlessO TaskHub = options.TaskHub, WorkerProfileId = workerProfileId, MaxActivitiesCount = options.MaxConcurrentActivities, - Substrate = GetSubstrateFromEnvironment(), - DtsSandboxIdentifier = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID") ?? string.Empty, + Substrate = ParseSubstrate(options.Substrate), + DtsSandboxIdentifier = options.DtsSandboxIdentifier ?? string.Empty, }; return new Proto.ServerlessActivityWorkerMessage { Start = start }; @@ -150,10 +150,9 @@ static Proto.ServerlessActivityResources BuildResources(ServerlessOptions option }; } - static Proto.SubstrateKind GetSubstrateFromEnvironment() + static Proto.SubstrateKind ParseSubstrate(string? substrate) { - string? substrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); - if (substrate is null) + if (string.IsNullOrWhiteSpace(substrate)) { return Proto.SubstrateKind.Unspecified; } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs index 4619350f..7b63255b 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs @@ -104,11 +104,26 @@ public sealed class ServerlessOptions /// public int MaxConcurrentActivities { get; set; } = 100; + /// + /// Gets or sets the substrate where this serverless worker is running. + /// + public string? Substrate { get; set; } + + /// + /// Gets or sets the DTS-generated sandbox identifier for this serverless worker. + /// + public string? DtsSandboxIdentifier { get; set; } + /// /// Gets or sets the interval used to refresh live worker capacity while the registration stream is open. /// public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(2); + /// + /// Gets or sets the private HTTP port used by ADC to wake or probe a serverless worker container. + /// + public int WakeupPort { get; set; } = 8080; + /// /// Gets or sets the initial delay before retrying a failed worker registration stream. /// @@ -120,12 +135,7 @@ public sealed class ServerlessOptions internal TimeSpan WorkerRegistrationRetryMaxDelay { get; set; } = TimeSpan.FromSeconds(30); /// - /// Gets or sets the private HTTP port used by ADC to wake or probe a serverless worker container. - /// - public int WakeupPort { get; set; } = 8080; - - /// - /// Gets or sets the worker mode for serverless activity execution. Set automatically from the runtime environment. + /// Gets or sets the worker mode for serverless activity execution. /// internal ServerlessMode Mode { get; set; } = ServerlessMode.LocalExclude; } diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index e0da0b07..61a53154 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -244,48 +244,39 @@ await action.Should().ThrowAsync() public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWorkerMetadataWithoutActivityCatalog() { // Arrange - string? originalSubstrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); - string? originalSandboxId = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID"); - Environment.SetEnvironmentVariable("DTS_SUBSTRATE", "Sandbox"); - Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", "sandbox-1"); - - try + using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "AcaSessionPool"); + using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "env-sandbox"); + ServerlessOptions options = new() { - ServerlessOptions options = new() - { - Mode = ServerlessMode.ServerlessInclude, - TaskHub = TaskHub, - WorkerProfileId = "profile-a", - MaxConcurrentActivities = 3, - HeartbeatInterval = TimeSpan.FromDays(1), - }; - options.ActivityNames.Add("RemoteHello"); - FakeServerlessActivitiesClient client = new(); - ServerlessActivityWorkerRegistrationHostedService service = new( - client, - options, - NullLogger.Instance); + Mode = ServerlessMode.ServerlessInclude, + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + HeartbeatInterval = TimeSpan.FromDays(1), + Substrate = "Sandbox", + DtsSandboxIdentifier = "explicit-sandbox", + }; + options.ActivityNames.Add("RemoteHello"); + FakeServerlessActivitiesClient client = new(); + ServerlessActivityWorkerRegistrationHostedService service = new( + client, + options, + NullLogger.Instance); - // Act - await service.StartAsync(CancellationToken.None); - await client.Session.WaitForMessageAsync(message => message.Start != null); - await service.StopAsync(CancellationToken.None); + // Act + await service.StartAsync(CancellationToken.None); + await client.Session.WaitForMessageAsync(message => message.Start != null); + await service.StopAsync(CancellationToken.None); - // Assert - client.SessionTaskHubs.Should().Equal(TaskHub); - ServerlessActivityWorkerMessage message = client.Session.Messages.Should().ContainSingle().Subject; - ServerlessActivityWorkerStart start = message.Start; - start.TaskHub.Should().Be(TaskHub); - start.WorkerProfileId.Should().Be("profile-a"); - start.MaxActivitiesCount.Should().Be(3); - start.Substrate.Should().Be(SubstrateKind.Sandbox); - start.DtsSandboxIdentifier.Should().Be("sandbox-1"); - } - finally - { - Environment.SetEnvironmentVariable("DTS_SUBSTRATE", originalSubstrate); - Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", originalSandboxId); - } + // Assert + client.SessionTaskHubs.Should().Equal(TaskHub); + ServerlessActivityWorkerMessage message = client.Session.Messages.Should().ContainSingle().Subject; + ServerlessActivityWorkerStart start = message.Start; + start.TaskHub.Should().Be(TaskHub); + start.WorkerProfileId.Should().Be("profile-a"); + start.MaxActivitiesCount.Should().Be(3); + start.Substrate.Should().Be(SubstrateKind.Sandbox); + start.DtsSandboxIdentifier.Should().Be("explicit-sandbox"); } [Fact] @@ -589,26 +580,37 @@ public async Task DeclareServerlessActivities_DoesNotConfigureFilterWhenActivity } [Fact] - public async Task UseServerlessWorker_ConfiguresServerlessActivityWorkerFilter() + public async Task UseServerlessWorker_ConfiguresServerlessActivityWorkerFromExplicitOptions() { // Arrange - using EnvironmentVariableScope serverlessActivities = new("DTS_SERVERLESS_ACTIVITIES", "RemoteHello"); + using EnvironmentVariableScope serverlessActivities = new("DTS_SERVERLESS_ACTIVITIES", "EnvActivity"); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "env-profile"); + using EnvironmentVariableScope maxActivities = new("DTS_SERVERLESS_MAX_ACTIVITIES", "9"); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - mockBuilder.Object.UseServerlessWorker(); + mockBuilder.Object.UseServerlessWorker(options => + { + options.ActivityNames.Add("RemoteHello"); + options.WorkerProfileId = "explicit-profile"; + options.MaxConcurrentActivities = 5; + }); await using ServiceProvider provider = services.BuildServiceProvider(); DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); + ServerlessOptions options = provider.GetRequiredService>().Get(Options.DefaultName); // Assert filters.Activities.Select(filter => filter.Name).Should().Equal("RemoteHello"); filters.ExcludedActivities.Should().BeEmpty(); filters.Orchestrations.Should().BeEmpty(); filters.Entities.Should().BeEmpty(); + options.Mode.Should().Be(ServerlessMode.ServerlessInclude); + options.WorkerProfileId.Should().Be("explicit-profile"); + options.MaxConcurrentActivities.Should().Be(5); } [Fact] From d5dcc196b2756b401aa47fbb9f74380f2ab065d3 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 21 May 2026 20:56:52 -0700 Subject: [PATCH 25/81] Revert "remove env var" This reverts commit 18c8e0209cfcc09752c4dc7b475f3a056092c720. --- samples/serverless/remote-worker/Program.cs | 36 +------- ...TaskSchedulerServerlessWorkerExtensions.cs | 74 ++++++++++++---- .../ServerlessActivityConfiguration.cs | 9 +- .../Worker/Serverless/ServerlessOptions.cs | 22 ++--- .../ServerlessActivitiesTests.cs | 86 +++++++++---------- 5 files changed, 111 insertions(+), 116 deletions(-) diff --git a/samples/serverless/remote-worker/Program.cs b/samples/serverless/remote-worker/Program.cs index ac9a9743..5389a5b1 100644 --- a/samples/serverless/remote-worker/Program.cs +++ b/samples/serverless/remote-worker/Program.cs @@ -12,11 +12,6 @@ string taskHub = Environment.GetEnvironmentVariable("DTS_TASK_HUB") ?? Environment.GetEnvironmentVariable("DTS_TASKHUB") ?? "ServerlessPocHub"; -string workerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID") ?? "default"; -string[] serverlessActivities = SplitEnvironmentList(Environment.GetEnvironmentVariable("DTS_SERVERLESS_ACTIVITIES")); -int maxConcurrentActivities = GetIntEnv("DTS_SERVERLESS_MAX_ACTIVITIES", 100); -string? substrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); -string? dtsSandboxIdentifier = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID"); HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); builder.Logging.AddSimpleConsole(options => @@ -37,18 +32,7 @@ options.EndpointAddress = endpoint; options.TaskHubName = taskHub; }); - workerBuilder.UseServerlessWorker(options => - { - options.TaskHub = taskHub; - options.WorkerProfileId = workerProfileId; - options.MaxConcurrentActivities = maxConcurrentActivities; - options.Substrate = substrate; - options.DtsSandboxIdentifier = dtsSandboxIdentifier; - foreach (string activityName in serverlessActivities) - { - options.ActivityNames.Add(activityName); - } - }); + workerBuilder.UseServerlessWorker(); }); await builder.Build().RunAsync(); @@ -56,21 +40,3 @@ static string GetRequiredEnvironmentVariable(string name) => Environment.GetEnvironmentVariable(name) ?? throw new InvalidOperationException($"An environment variable named '{name}' is required."); - -static string[] SplitEnvironmentList(string? value) - => string.IsNullOrWhiteSpace(value) - ? [] - : value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - -static int GetIntEnv(string name, int defaultValue) -{ - string? value = Environment.GetEnvironmentVariable(name); - if (string.IsNullOrWhiteSpace(value)) - { - return defaultValue; - } - - return int.TryParse(value, out int parsed) && parsed > 0 - ? parsed - : throw new InvalidOperationException($"Environment variable '{name}' must be a positive integer."); -} diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs index 7f5b0414..c98bd203 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -21,7 +21,8 @@ namespace Microsoft.DurableTask.Worker.AzureManaged; public static class DurableTaskSchedulerServerlessWorkerExtensions { /// - /// Declares serverless activities with DTS and excludes them from local execution. + /// Declares serverless activities with DTS, excludes them from local execution, and propagates the + /// activity list to sandbox workers via the DTS_SERVERLESS_ACTIVITIES environment variable. /// Call this on the local coordinator worker — not on the sandbox worker binary. /// /// The Durable Task worker builder to configure. @@ -50,6 +51,7 @@ public static IDurableTaskWorkerBuilder DeclareServerlessActivities( /// /// Configures this worker as a serverless activity worker that connects to DTS to receive and execute /// serverless activities. Use this on a dedicated worker binary that runs inside serverless infrastructure. + /// All configuration is read from environment variables injected by the backend and coordinator. /// /// /// @@ -57,34 +59,25 @@ public static IDurableTaskWorkerBuilder DeclareServerlessActivities( /// to declare and provision the serverless activity configuration. /// /// - /// Pass any environment-derived values explicitly through the configure callback or pre-configured options. + /// Required environment variables (injected automatically by the backend and coordinator): + /// + /// DTS_SUBSTRATE — identifies the sandbox substrate (injected by backend) + /// DTS_SERVERLESS_ACTIVITIES — comma-separated activity names to execute (injected by coordinator) + /// DTS_TASK_HUB — task hub name (injected by coordinator) + /// /// /// /// The Durable Task worker builder to configure. /// The original builder, for call chaining. public static IDurableTaskWorkerBuilder UseServerlessWorker(this IDurableTaskWorkerBuilder builder) - => UseServerlessWorker(builder, static _ => { }); - - /// - /// Configures this worker as a serverless activity worker that connects to DTS to receive and execute - /// serverless activities. Use this on a dedicated worker binary that runs inside serverless infrastructure. - /// - /// The Durable Task worker builder to configure. - /// Callback to configure serverless worker behavior. - /// The original builder, for call chaining. - public static IDurableTaskWorkerBuilder UseServerlessWorker( - this IDurableTaskWorkerBuilder builder, - Action configure) { Check.NotNull(builder); - Check.NotNull(configure); builder.Services.AddOptions(builder.Name) - .Configure(configure) .PostConfigure>((options, schedulerOptions) => { ApplyTaskHubDefault(options, schedulerOptions.Get(builder.Name).TaskHubName); - options.Mode = ServerlessMode.ServerlessInclude; + ApplyWorkerEnvironmentOverrides(options); }); builder.Services.AddOptions(builder.Name) @@ -204,6 +197,53 @@ static void ApplyTaskHubDefault(ServerlessOptions options, string taskHubName) } } + static void ApplyWorkerEnvironmentOverrides(ServerlessOptions options) + { + // Auto-detect worker mode from DTS_SUBSTRATE, which the backend injects when + // launching a sandbox. This is the authoritative signal that this process is a sandbox worker. + string? substrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); + if (string.Equals(substrate, "Sandbox", StringComparison.OrdinalIgnoreCase) + || string.Equals(substrate, "AcaSessionPool", StringComparison.OrdinalIgnoreCase)) + { + options.Mode = ServerlessMode.ServerlessInclude; + } + + // DTS_SERVERLESS_ACTIVITIES is injected by the coordinator into the sandbox environment. + ApplyActivityNameEnvironmentOverride(options.ActivityNames); + ApplyWorkerProfileEnvironmentOverride(profile => options.WorkerProfileId = profile); + + if (int.TryParse(Environment.GetEnvironmentVariable("DTS_SERVERLESS_MAX_ACTIVITIES"), out int maxActivities) && maxActivities > 0) + { + options.MaxConcurrentActivities = maxActivities; + } + } + + static void ApplyActivityNameEnvironmentOverride(ICollection activityNames) + { + string? serverlessActivities = Environment.GetEnvironmentVariable("DTS_SERVERLESS_ACTIVITIES"); + if (serverlessActivities is null) + { + return; + } + + activityNames.Clear(); + foreach (string name in serverlessActivities + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Distinct(StringComparer.Ordinal)) + { + activityNames.Add(name); + } + } + + static void ApplyWorkerProfileEnvironmentOverride(Action setWorkerProfileId) + { + string? workerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID"); + if (!string.IsNullOrWhiteSpace(workerProfileId)) + { + setWorkerProfileId(workerProfileId.Trim()); + } + } + static DurableTaskWorkerWorkItemFilters.ActivityFilter[] MergeActivityFilters( IReadOnlyList existingFilters, IEnumerable activityNames) diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs index 90361c23..85fc0645 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs @@ -87,8 +87,8 @@ public static Proto.ServerlessActivityWorkerMessage BuildWorkerStart(ServerlessO TaskHub = options.TaskHub, WorkerProfileId = workerProfileId, MaxActivitiesCount = options.MaxConcurrentActivities, - Substrate = ParseSubstrate(options.Substrate), - DtsSandboxIdentifier = options.DtsSandboxIdentifier ?? string.Empty, + Substrate = GetSubstrateFromEnvironment(), + DtsSandboxIdentifier = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID") ?? string.Empty, }; return new Proto.ServerlessActivityWorkerMessage { Start = start }; @@ -150,9 +150,10 @@ static Proto.ServerlessActivityResources BuildResources(ServerlessOptions option }; } - static Proto.SubstrateKind ParseSubstrate(string? substrate) + static Proto.SubstrateKind GetSubstrateFromEnvironment() { - if (string.IsNullOrWhiteSpace(substrate)) + string? substrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); + if (substrate is null) { return Proto.SubstrateKind.Unspecified; } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs index 7b63255b..4619350f 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs @@ -104,26 +104,11 @@ public sealed class ServerlessOptions /// public int MaxConcurrentActivities { get; set; } = 100; - /// - /// Gets or sets the substrate where this serverless worker is running. - /// - public string? Substrate { get; set; } - - /// - /// Gets or sets the DTS-generated sandbox identifier for this serverless worker. - /// - public string? DtsSandboxIdentifier { get; set; } - /// /// Gets or sets the interval used to refresh live worker capacity while the registration stream is open. /// public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(2); - /// - /// Gets or sets the private HTTP port used by ADC to wake or probe a serverless worker container. - /// - public int WakeupPort { get; set; } = 8080; - /// /// Gets or sets the initial delay before retrying a failed worker registration stream. /// @@ -135,7 +120,12 @@ public sealed class ServerlessOptions internal TimeSpan WorkerRegistrationRetryMaxDelay { get; set; } = TimeSpan.FromSeconds(30); /// - /// Gets or sets the worker mode for serverless activity execution. + /// Gets or sets the private HTTP port used by ADC to wake or probe a serverless worker container. + /// + public int WakeupPort { get; set; } = 8080; + + /// + /// Gets or sets the worker mode for serverless activity execution. Set automatically from the runtime environment. /// internal ServerlessMode Mode { get; set; } = ServerlessMode.LocalExclude; } diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index 61a53154..e0da0b07 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -244,39 +244,48 @@ await action.Should().ThrowAsync() public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWorkerMetadataWithoutActivityCatalog() { // Arrange - using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "AcaSessionPool"); - using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "env-sandbox"); - ServerlessOptions options = new() + string? originalSubstrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); + string? originalSandboxId = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID"); + Environment.SetEnvironmentVariable("DTS_SUBSTRATE", "Sandbox"); + Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", "sandbox-1"); + + try { - Mode = ServerlessMode.ServerlessInclude, - TaskHub = TaskHub, - WorkerProfileId = "profile-a", - MaxConcurrentActivities = 3, - HeartbeatInterval = TimeSpan.FromDays(1), - Substrate = "Sandbox", - DtsSandboxIdentifier = "explicit-sandbox", - }; - options.ActivityNames.Add("RemoteHello"); - FakeServerlessActivitiesClient client = new(); - ServerlessActivityWorkerRegistrationHostedService service = new( - client, - options, - NullLogger.Instance); + ServerlessOptions options = new() + { + Mode = ServerlessMode.ServerlessInclude, + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + HeartbeatInterval = TimeSpan.FromDays(1), + }; + options.ActivityNames.Add("RemoteHello"); + FakeServerlessActivitiesClient client = new(); + ServerlessActivityWorkerRegistrationHostedService service = new( + client, + options, + NullLogger.Instance); - // Act - await service.StartAsync(CancellationToken.None); - await client.Session.WaitForMessageAsync(message => message.Start != null); - await service.StopAsync(CancellationToken.None); + // Act + await service.StartAsync(CancellationToken.None); + await client.Session.WaitForMessageAsync(message => message.Start != null); + await service.StopAsync(CancellationToken.None); - // Assert - client.SessionTaskHubs.Should().Equal(TaskHub); - ServerlessActivityWorkerMessage message = client.Session.Messages.Should().ContainSingle().Subject; - ServerlessActivityWorkerStart start = message.Start; - start.TaskHub.Should().Be(TaskHub); - start.WorkerProfileId.Should().Be("profile-a"); - start.MaxActivitiesCount.Should().Be(3); - start.Substrate.Should().Be(SubstrateKind.Sandbox); - start.DtsSandboxIdentifier.Should().Be("explicit-sandbox"); + // Assert + client.SessionTaskHubs.Should().Equal(TaskHub); + ServerlessActivityWorkerMessage message = client.Session.Messages.Should().ContainSingle().Subject; + ServerlessActivityWorkerStart start = message.Start; + start.TaskHub.Should().Be(TaskHub); + start.WorkerProfileId.Should().Be("profile-a"); + start.MaxActivitiesCount.Should().Be(3); + start.Substrate.Should().Be(SubstrateKind.Sandbox); + start.DtsSandboxIdentifier.Should().Be("sandbox-1"); + } + finally + { + Environment.SetEnvironmentVariable("DTS_SUBSTRATE", originalSubstrate); + Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", originalSandboxId); + } } [Fact] @@ -580,37 +589,26 @@ public async Task DeclareServerlessActivities_DoesNotConfigureFilterWhenActivity } [Fact] - public async Task UseServerlessWorker_ConfiguresServerlessActivityWorkerFromExplicitOptions() + public async Task UseServerlessWorker_ConfiguresServerlessActivityWorkerFilter() { // Arrange - using EnvironmentVariableScope serverlessActivities = new("DTS_SERVERLESS_ACTIVITIES", "EnvActivity"); - using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "env-profile"); - using EnvironmentVariableScope maxActivities = new("DTS_SERVERLESS_MAX_ACTIVITIES", "9"); + using EnvironmentVariableScope serverlessActivities = new("DTS_SERVERLESS_ACTIVITIES", "RemoteHello"); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - mockBuilder.Object.UseServerlessWorker(options => - { - options.ActivityNames.Add("RemoteHello"); - options.WorkerProfileId = "explicit-profile"; - options.MaxConcurrentActivities = 5; - }); + mockBuilder.Object.UseServerlessWorker(); await using ServiceProvider provider = services.BuildServiceProvider(); DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); - ServerlessOptions options = provider.GetRequiredService>().Get(Options.DefaultName); // Assert filters.Activities.Select(filter => filter.Name).Should().Equal("RemoteHello"); filters.ExcludedActivities.Should().BeEmpty(); filters.Orchestrations.Should().BeEmpty(); filters.Entities.Should().BeEmpty(); - options.Mode.Should().Be(ServerlessMode.ServerlessInclude); - options.WorkerProfileId.Should().Be("explicit-profile"); - options.MaxConcurrentActivities.Should().Be(5); } [Fact] From 408ee7171c5c3b3d98c9d9937ce34c167ae104c6 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 22 May 2026 10:48:41 -0700 Subject: [PATCH 26/81] Enhance serverless worker registration and configuration - Updated README.md to clarify remote worker image settings. - Simplified task hub retrieval in Program.cs. - Removed unnecessary endpoint configuration in remote worker. - Added Azure.Identity package reference in csproj. - Refined serverless worker extensions for environment configuration. - Updated serverless activity configuration to handle registered activities. - Modified tests to reflect changes in activity registration and filtering. --- samples/serverless/README.md | 4 + samples/serverless/main-app/Program.cs | 5 +- samples/serverless/remote-worker/Program.cs | 14 ---- .../AzureManagedServerless.csproj | 1 + ...TaskSchedulerServerlessWorkerExtensions.cs | 77 ++++++++++--------- .../ServerlessActivityConfiguration.cs | 14 +++- ...ActivityWorkerRegistrationHostedService.cs | 8 +- src/Grpc/serverless_activities_service.proto | 3 + .../ServerlessActivitiesTests.cs | 17 ++-- 9 files changed, 78 insertions(+), 65 deletions(-) diff --git a/samples/serverless/README.md b/samples/serverless/README.md index 8faff0e2..d6ce29b1 100644 --- a/samples/serverless/README.md +++ b/samples/serverless/README.md @@ -51,3 +51,7 @@ Output: "hello from pid=: serverless-sample" Use the Durable Task Scheduler dashboard's Serverless Activities preview tab to inspect serverless activity runtimes and stream runtime logs. +The remote worker image does not need customer-provided DTS runtime settings. +DTS injects the scheduler endpoint, task hub, worker profile, capacity, substrate, +and sandbox identifier when it starts the sandbox. The worker reports the +activities registered in the image when it connects. diff --git a/samples/serverless/main-app/Program.cs b/samples/serverless/main-app/Program.cs index 84da2345..f5ca7c25 100644 --- a/samples/serverless/main-app/Program.cs +++ b/samples/serverless/main-app/Program.cs @@ -14,9 +14,7 @@ using Microsoft.Extensions.Logging; string endpoint = GetRequiredEnvironmentVariable("DTS_ENDPOINT"); -string taskHub = Environment.GetEnvironmentVariable("DTS_TASK_HUB") - ?? Environment.GetEnvironmentVariable("DTS_TASKHUB") - ?? "ServerlessPocHub"; +string taskHub = Environment.GetEnvironmentVariable("DTS_TASK_HUB") ?? "ServerlessPocHub"; string workerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID") ?? "default"; string serverlessActivityImage = Environment.GetEnvironmentVariable("DTS_SERVERLESS_ACTIVITY_IMAGE") ?? "serverless-remote-worker:local"; @@ -51,7 +49,6 @@ options.Cpu = Environment.GetEnvironmentVariable("DTS_SERVERLESS_CPU") ?? "1000m"; options.Memory = Environment.GetEnvironmentVariable("DTS_SERVERLESS_MEMORY") ?? "2048Mi"; options.MaxConcurrentActivities = GetIntEnv("DTS_SERVERLESS_MAX_ACTIVITIES", 1); - options.EnvironmentVariables["DTS_ENDPOINT"] = endpoint; options.ActivityNames.Add(ServerlessTaskNames.RemoteHello); }); }); diff --git a/samples/serverless/remote-worker/Program.cs b/samples/serverless/remote-worker/Program.cs index 5389a5b1..b705fba5 100644 --- a/samples/serverless/remote-worker/Program.cs +++ b/samples/serverless/remote-worker/Program.cs @@ -8,11 +8,6 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -string endpoint = GetRequiredEnvironmentVariable("DTS_ENDPOINT"); -string taskHub = Environment.GetEnvironmentVariable("DTS_TASK_HUB") - ?? Environment.GetEnvironmentVariable("DTS_TASKHUB") - ?? "ServerlessPocHub"; - HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); builder.Logging.AddSimpleConsole(options => { @@ -27,16 +22,7 @@ { tasks.AddActivity(); }); - workerBuilder.UseDurableTaskScheduler(options => - { - options.EndpointAddress = endpoint; - options.TaskHubName = taskHub; - }); workerBuilder.UseServerlessWorker(); }); await builder.Build().RunAsync(); - -static string GetRequiredEnvironmentVariable(string name) - => Environment.GetEnvironmentVariable(name) - ?? throw new InvalidOperationException($"An environment variable named '{name}' is required."); diff --git a/src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj b/src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj index 578fe888..bc212683 100644 --- a/src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj +++ b/src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs index c98bd203..5179a696 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; +using Azure.Identity; using Grpc.Net.Client; using Microsoft.DurableTask.Protobuf.Serverless; using Microsoft.DurableTask.Worker.AzureManaged.Serverless; @@ -21,8 +22,7 @@ namespace Microsoft.DurableTask.Worker.AzureManaged; public static class DurableTaskSchedulerServerlessWorkerExtensions { /// - /// Declares serverless activities with DTS, excludes them from local execution, and propagates the - /// activity list to sandbox workers via the DTS_SERVERLESS_ACTIVITIES environment variable. + /// Declares serverless activities with DTS and excludes them from local execution. /// Call this on the local coordinator worker — not on the sandbox worker binary. /// /// The Durable Task worker builder to configure. @@ -51,7 +51,7 @@ public static IDurableTaskWorkerBuilder DeclareServerlessActivities( /// /// Configures this worker as a serverless activity worker that connects to DTS to receive and execute /// serverless activities. Use this on a dedicated worker binary that runs inside serverless infrastructure. - /// All configuration is read from environment variables injected by the backend and coordinator. + /// Runtime configuration is read from environment variables injected by DTS. /// /// /// @@ -59,11 +59,11 @@ public static IDurableTaskWorkerBuilder DeclareServerlessActivities( /// to declare and provision the serverless activity configuration. /// /// - /// Required environment variables (injected automatically by the backend and coordinator): + /// Required environment variables injected automatically by DTS: /// - /// DTS_SUBSTRATE — identifies the sandbox substrate (injected by backend) - /// DTS_SERVERLESS_ACTIVITIES — comma-separated activity names to execute (injected by coordinator) - /// DTS_TASK_HUB — task hub name (injected by coordinator) + /// DTS_ENDPOINT — canonical scheduler endpoint + /// DTS_TASK_HUB — task hub name from the declaration + /// DTS_SUBSTRATE — identifies the sandbox substrate /// /// /// @@ -73,6 +73,9 @@ public static IDurableTaskWorkerBuilder UseServerlessWorker(this IDurableTaskWor { Check.NotNull(builder); + ConfigureDurableTaskSchedulerFromEnvironment(builder); + builder.UseWorkItemFilters(); + builder.Services.AddOptions(builder.Name) .PostConfigure>((options, schedulerOptions) => { @@ -81,8 +84,7 @@ public static IDurableTaskWorkerBuilder UseServerlessWorker(this IDurableTaskWor }); builder.Services.AddOptions(builder.Name) - .PostConfigure>( - (filters, serverlessOptions) => IncludeOnlyServerlessActivities(filters, serverlessOptions.Get(builder.Name))); + .PostConfigure(IncludeOnlyRegisteredActivities); builder.Services.AddSingleton(); builder.Services.AddOptions(builder.Name) @@ -115,18 +117,9 @@ static void ExcludeServerlessActivitiesFromLocalExecution(DurableTaskWorkerWorkI filters.ExcludedActivities = MergeActivityFilters(filters.ExcludedActivities, activityNames); } - static void IncludeOnlyServerlessActivities(DurableTaskWorkerWorkItemFilters filters, ServerlessOptions options) + static void IncludeOnlyRegisteredActivities(DurableTaskWorkerWorkItemFilters filters) { - string[] activityNames = ServerlessActivityConfiguration.ResolveActivityNames(options.ActivityNames); - if (activityNames.Length == 0) - { - return; - } - filters.Orchestrations = []; - filters.Activities = activityNames - .Select(static name => new DurableTaskWorkerWorkItemFilters.ActivityFilter { Name = name }) - .ToArray(); filters.ExcludedActivities = []; filters.Entities = []; } @@ -152,10 +145,12 @@ static ServerlessActivityWorkerRegistrationHostedService CreateServerlessActivit ILoggerFactory loggerFactory = services.GetRequiredService(); IHostApplicationLifetime? lifetime = services.GetService(); ServerlessActivityTracker activityTracker = services.GetRequiredService(); + DurableTaskWorkerWorkItemFilters filters = services.GetRequiredService>().Get(builderName); return new ServerlessActivityWorkerRegistrationHostedService( CreateServerlessActivitiesClient(services, builderName), options, + ResolveActivityFilterNames(filters.Activities), loggerFactory.CreateLogger(), lifetime, activityTracker); @@ -197,6 +192,21 @@ static void ApplyTaskHubDefault(ServerlessOptions options, string taskHubName) } } + static void ConfigureDurableTaskSchedulerFromEnvironment(IDurableTaskWorkerBuilder builder) + { + string? endpoint = Environment.GetEnvironmentVariable("DTS_ENDPOINT"); + string? taskHub = Environment.GetEnvironmentVariable("DTS_TASK_HUB"); + if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(taskHub)) + { + return; + } + + // Private preview: DTS-owned sandbox workers authenticate with the injected + // managed identity via DefaultAzureCredential. Revisit this if customer-owned + // worker identities or non-default auth modes are introduced. + builder.UseDurableTaskScheduler(endpoint.Trim(), taskHub.Trim(), new DefaultAzureCredential()); + } + static void ApplyWorkerEnvironmentOverrides(ServerlessOptions options) { // Auto-detect worker mode from DTS_SUBSTRATE, which the backend injects when @@ -208,8 +218,6 @@ static void ApplyWorkerEnvironmentOverrides(ServerlessOptions options) options.Mode = ServerlessMode.ServerlessInclude; } - // DTS_SERVERLESS_ACTIVITIES is injected by the coordinator into the sandbox environment. - ApplyActivityNameEnvironmentOverride(options.ActivityNames); ApplyWorkerProfileEnvironmentOverride(profile => options.WorkerProfileId = profile); if (int.TryParse(Environment.GetEnvironmentVariable("DTS_SERVERLESS_MAX_ACTIVITIES"), out int maxActivities) && maxActivities > 0) @@ -218,23 +226,6 @@ static void ApplyWorkerEnvironmentOverrides(ServerlessOptions options) } } - static void ApplyActivityNameEnvironmentOverride(ICollection activityNames) - { - string? serverlessActivities = Environment.GetEnvironmentVariable("DTS_SERVERLESS_ACTIVITIES"); - if (serverlessActivities is null) - { - return; - } - - activityNames.Clear(); - foreach (string name in serverlessActivities - .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) - .Distinct(StringComparer.Ordinal)) - { - activityNames.Add(name); - } - } - static void ApplyWorkerProfileEnvironmentOverride(Action setWorkerProfileId) { string? workerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID"); @@ -264,4 +255,14 @@ static DurableTaskWorkerWorkItemFilters.ActivityFilter[] MergeActivityFilters( return merged.Values.ToArray(); } + + static string[] ResolveActivityFilterNames(IReadOnlyList activityFilters) + { + return activityFilters + .Select(static filter => filter.Name) + .Where(static name => !string.IsNullOrWhiteSpace(name)) + .Select(static name => name.Trim()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs index 85fc0645..c9a3f5e7 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs @@ -15,7 +15,7 @@ static class ServerlessActivityConfiguration /// /// The configured activity names. /// The normalized activity names. - public static string[] ResolveActivityNames(ICollection configuredNames) + public static string[] ResolveActivityNames(IEnumerable configuredNames) { return configuredNames .Where(static name => !string.IsNullOrWhiteSpace(name)) @@ -68,12 +68,21 @@ public static Proto.ServerlessActivityDeclaration BuildDeclaration(ServerlessOpt /// Builds the initial serverless activity worker registration message. /// /// The serverless options. + /// The activity handlers registered by the worker process. /// The worker start protocol message. - public static Proto.ServerlessActivityWorkerMessage BuildWorkerStart(ServerlessOptions options) + public static Proto.ServerlessActivityWorkerMessage BuildWorkerStart( + ServerlessOptions options, + IReadOnlyCollection registeredActivityNames) { Check.NotNull(options); + Check.NotNull(registeredActivityNames); ValidateTaskHub(options.TaskHub, "Serverless activity worker registration requires a task hub name."); + string[] activityNames = ResolveActivityNames(registeredActivityNames); + if (activityNames.Length == 0) + { + throw new InvalidOperationException("Serverless activity worker registration requires at least one registered activity."); + } if (options.MaxConcurrentActivities <= 0) { @@ -90,6 +99,7 @@ public static Proto.ServerlessActivityWorkerMessage BuildWorkerStart(ServerlessO Substrate = GetSubstrateFromEnvironment(), DtsSandboxIdentifier = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID") ?? string.Empty, }; + start.ActivityNames.AddRange(activityNames); return new Proto.ServerlessActivityWorkerMessage { Start = start }; } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs index ca51abc7..ce506c10 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs @@ -17,6 +17,7 @@ sealed class ServerlessActivityWorkerRegistrationHostedService : IHostedService, readonly object sync = new(); readonly IServerlessActivitiesClient client; readonly ServerlessOptions options; + readonly IReadOnlyCollection registeredActivityNames; readonly ILogger logger; readonly IHostApplicationLifetime? lifetime; readonly ServerlessActivityTracker? activityTracker; @@ -31,6 +32,7 @@ sealed class ServerlessActivityWorkerRegistrationHostedService : IHostedService, /// /// The serverless activities client. /// The serverless options. + /// The activity handlers registered by this worker process. /// The logger. /// The optional application lifetime used to stop the host when a non-retriable registration stream failure occurs. /// The optional activity tracker used to report live in-flight activity count. @@ -38,6 +40,7 @@ sealed class ServerlessActivityWorkerRegistrationHostedService : IHostedService, public ServerlessActivityWorkerRegistrationHostedService( IServerlessActivitiesClient client, ServerlessOptions options, + IReadOnlyCollection registeredActivityNames, ILogger logger, IHostApplicationLifetime? lifetime = null, ServerlessActivityTracker? activityTracker = null, @@ -45,6 +48,7 @@ public ServerlessActivityWorkerRegistrationHostedService( { this.client = Check.NotNull(client); this.options = Check.NotNull(options); + this.registeredActivityNames = Check.NotNull(registeredActivityNames); this.logger = Check.NotNull(logger); this.lifetime = lifetime; this.activityTracker = activityTracker; @@ -60,7 +64,7 @@ public Task StartAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - string[] activityNames = ServerlessActivityConfiguration.ResolveActivityNames(this.options.ActivityNames); + string[] activityNames = ServerlessActivityConfiguration.ResolveActivityNames(this.registeredActivityNames); if (activityNames.Length == 0) { Logs.NoServerlessActivitiesForWorkerRegistration(this.logger, this.options.TaskHub); @@ -195,7 +199,7 @@ async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancell registrationSession = this.client.OpenServerlessActivityWorkerSession(this.options.TaskHub, cancellationToken); this.SetCurrentSession(registrationSession); - Proto.ServerlessActivityWorkerMessage startMessage = ServerlessActivityConfiguration.BuildWorkerStart(this.options); + Proto.ServerlessActivityWorkerMessage startMessage = ServerlessActivityConfiguration.BuildWorkerStart(this.options, this.registeredActivityNames); await this.WriteSessionMessageAsync(registrationSession, startMessage, cancellationToken).ConfigureAwait(false); Logs.ServerlessActivityWorkerRegistered( this.logger, diff --git a/src/Grpc/serverless_activities_service.proto b/src/Grpc/serverless_activities_service.proto index 184006d2..f37cd62a 100644 --- a/src/Grpc/serverless_activities_service.proto +++ b/src/Grpc/serverless_activities_service.proto @@ -41,6 +41,9 @@ message ServerlessActivityWorkerStart { // the ADC provider sandbox resource id. string dts_sandbox_identifier = 5; string worker_profile_id = 6; + // Activity handlers registered by the worker process. DTS validates this + // matches the declaration before advertising worker capacity. + repeated string activity_names = 7; } message ServerlessActivityWorkerHeartbeat { diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index e0da0b07..18f99a4a 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -241,7 +241,7 @@ await action.Should().ThrowAsync() } [Fact] - public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWorkerMetadataWithoutActivityCatalog() + public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWorkerMetadataWithRegisteredActivities() { // Arrange string? originalSubstrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); @@ -264,6 +264,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWor ServerlessActivityWorkerRegistrationHostedService service = new( client, options, + ["RemoteHello"], NullLogger.Instance); // Act @@ -280,6 +281,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWor start.MaxActivitiesCount.Should().Be(3); start.Substrate.Should().Be(SubstrateKind.Sandbox); start.DtsSandboxIdentifier.Should().Be("sandbox-1"); + start.ActivityNames.Should().Equal("RemoteHello"); } finally { @@ -337,6 +339,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_SendsHeartbe ServerlessActivityWorkerRegistrationHostedService service = new( client, options, + ["RemoteHello"], NullLogger.Instance, activityTracker: activityTracker); @@ -377,6 +380,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_ReopensSessi ServerlessActivityWorkerRegistrationHostedService service = new( client, options, + ["RemoteHello"], NullLogger.Instance); // Act @@ -416,6 +420,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_ReopensSessi ServerlessActivityWorkerRegistrationHostedService service = new( client, options, + ["RemoteHello"], NullLogger.Instance); // Act @@ -484,6 +489,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_AppliesJitte ServerlessActivityWorkerRegistrationHostedService service = new( client, options, + ["RemoteHello"], NullLogger.Instance, reconnectJitter: new DeterministicRandom(0.0)); @@ -518,6 +524,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_StopAsync_Do ServerlessActivityWorkerRegistrationHostedService service = new( client, options, + ["RemoteHello"], NullLogger.Instance); // Act @@ -541,7 +548,6 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_StopAsync_Do public async Task DeclareServerlessActivities_ConfiguresLocalWorkerExclusionFilter() { // Arrange - using EnvironmentVariableScope serverlessActivities = new("DTS_SERVERLESS_ACTIVITIES", null); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); @@ -567,7 +573,6 @@ public async Task DeclareServerlessActivities_ConfiguresLocalWorkerExclusionFilt public async Task DeclareServerlessActivities_DoesNotConfigureFilterWhenActivityNamesAreEmpty() { // Arrange - using EnvironmentVariableScope serverlessActivities = new("DTS_SERVERLESS_ACTIVITIES", null); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); @@ -589,11 +594,13 @@ public async Task DeclareServerlessActivities_DoesNotConfigureFilterWhenActivity } [Fact] - public async Task UseServerlessWorker_ConfiguresServerlessActivityWorkerFilter() + public async Task UseServerlessWorker_ConfiguresRegisteredActivityWorkerFilter() { // Arrange - using EnvironmentVariableScope serverlessActivities = new("DTS_SERVERLESS_ACTIVITIES", "RemoteHello"); ServiceCollection services = new(); + services.Configure( + Options.DefaultName, + registry => registry.AddActivityFunc(new TaskName("RemoteHello"), (_, input) => input)); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); From 008a416e66e95737a0cdce307c882b4894c7cf42 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 22 May 2026 11:29:32 -0700 Subject: [PATCH 27/81] Remove serverless wakeup listener --- .../AzureManagedServerless.csproj | 1 - ...TaskSchedulerServerlessWorkerExtensions.cs | 11 -- .../Worker/Serverless/ServerlessOptions.cs | 7 +- .../Serverless/ServerlessWakeupServer.cs | 102 ------------------ .../ServerlessActivitiesTests.cs | 52 +-------- 5 files changed, 5 insertions(+), 168 deletions(-) delete mode 100644 src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWakeupServer.cs diff --git a/src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj b/src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj index bc212683..413b65ea 100644 --- a/src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj +++ b/src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj @@ -8,7 +8,6 @@ - diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs index 5179a696..c6889ad7 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -102,7 +102,6 @@ public static IDurableTaskWorkerBuilder UseServerlessWorker(this IDurableTaskWor })); builder.Services.AddSingleton(sp => CreateServerlessActivityWorkerRegistrationHostedService(sp, builder.Name)); - builder.Services.AddSingleton(sp => CreateServerlessWakeupServer(sp, builder.Name)); return builder; } @@ -156,16 +155,6 @@ static ServerlessActivityWorkerRegistrationHostedService CreateServerlessActivit activityTracker); } - static ServerlessWakeupServer CreateServerlessWakeupServer(IServiceProvider services, string builderName) - { - ServerlessOptions options = services.GetRequiredService>().Get(builderName); - ILoggerFactory loggerFactory = services.GetRequiredService(); - - return new ServerlessWakeupServer( - options, - loggerFactory.CreateLogger()); - } - static ServerlessActivitiesClientAdapter CreateServerlessActivitiesClient(IServiceProvider services, string builderName) { GrpcDurableTaskWorkerOptions options = services.GetRequiredService>().Get(builderName); diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs index 4619350f..d30d88bc 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs @@ -107,7 +107,7 @@ public sealed class ServerlessOptions /// /// Gets or sets the interval used to refresh live worker capacity while the registration stream is open. /// - public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(2); + internal TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(2); /// /// Gets or sets the initial delay before retrying a failed worker registration stream. @@ -119,11 +119,6 @@ public sealed class ServerlessOptions /// internal TimeSpan WorkerRegistrationRetryMaxDelay { get; set; } = TimeSpan.FromSeconds(30); - /// - /// Gets or sets the private HTTP port used by ADC to wake or probe a serverless worker container. - /// - public int WakeupPort { get; set; } = 8080; - /// /// Gets or sets the worker mode for serverless activity execution. Set automatically from the runtime environment. /// diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWakeupServer.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWakeupServer.cs deleted file mode 100644 index 2250f344..00000000 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWakeupServer.cs +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; - -/// -/// Hosts a private HTTP listener that wakes or probes a serverless worker container. -/// -public sealed partial class ServerlessWakeupServer : IHostedService, IAsyncDisposable -{ - readonly ServerlessOptions options; - readonly ILogger logger; - WebApplication? app; - - /// - /// Initializes a new instance of the class. - /// - /// The serverless options. - /// The logger. - public ServerlessWakeupServer(ServerlessOptions options, ILogger logger) - { - this.options = Check.NotNull(options); - this.logger = Check.NotNull(logger); - } - - /// - public async Task StartAsync(CancellationToken cancellationToken) - { - if (this.options.Mode != ServerlessMode.ServerlessInclude || this.app is not null) - { - return; - } - - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.WebHost.ConfigureKestrel(options => options.ListenAnyIP(this.options.WakeupPort)); - builder.Logging.ClearProviders(); - - WebApplication localApp = builder.Build(); - localApp.MapPost("/", static () => Results.Ok()); - localApp.MapPost("/wakeup", static () => Results.Ok()); - localApp.MapGet("/health", static () => Results.Ok()); - - try - { - await localApp.StartAsync(cancellationToken).ConfigureAwait(false); - this.app = localApp; - } - catch - { - await localApp.DisposeAsync().ConfigureAwait(false); - throw; - } - - Log.Started(this.logger, this.options.WakeupPort); - } - - /// - public async Task StopAsync(CancellationToken cancellationToken) - { - WebApplication? localApp = this.app; - this.app = null; - - if (localApp is null) - { - return; - } - - try - { - await localApp.StopAsync(cancellationToken).ConfigureAwait(false); - } - finally - { - await localApp.DisposeAsync().ConfigureAwait(false); - Log.Stopped(this.logger, this.options.WakeupPort); - } - } - - /// - public ValueTask DisposeAsync() => new(this.StopAsync(CancellationToken.None)); - - static partial class Log - { - [LoggerMessage( - EventId = 1, - Level = LogLevel.Information, - Message = "Serverless wakeup server listening on port {Port}")] - public static partial void Started(ILogger logger, int port); - - [LoggerMessage( - EventId = 2, - Level = LogLevel.Information, - Message = "Serverless wakeup server stopped on port {Port}")] - public static partial void Stopped(ILogger logger, int port); - } -} diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index 18f99a4a..97680223 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Net; -using System.Net.Sockets; using FluentAssertions; using Grpc.Core; using Microsoft.DurableTask.Protobuf.Serverless; @@ -27,6 +25,8 @@ public void ServerlessDeclarationContract_DoesNotExposeRemovedOptions() typeof(ServerlessOptions).GetProperty("LaunchCommand").Should().BeNull(); typeof(ServerlessOptions).GetProperty("DeclarationRetryMaxAttempts").Should().BeNull(); typeof(ServerlessOptions).GetProperty("DeclarationRetryDelay").Should().BeNull(); + typeof(ServerlessOptions).GetProperty("HeartbeatInterval").Should().BeNull(); + typeof(ServerlessOptions).GetProperty("WakeupPort").Should().BeNull(); typeof(ServerlessActivityDeclaration).GetProperty("LaunchCommand").Should().BeNull(); } @@ -619,7 +619,7 @@ public async Task UseServerlessWorker_ConfiguresRegisteredActivityWorkerFilter() } [Fact] - public void UseServerlessWorker_RegistersWakeupServerHostedService() + public void UseServerlessWorker_DoesNotRegisterWakeupServerHostedService() { // Arrange ServiceCollection services = new(); @@ -631,44 +631,7 @@ public void UseServerlessWorker_RegistersWakeupServerHostedService() mockBuilder.Object.UseServerlessWorker(); // Assert - services.Count(descriptor => descriptor.ServiceType == typeof(IHostedService)).Should().Be(2); - } - - [Fact] - public async Task ServerlessWakeupServer_RespondsToAdcProbesWhenWorkerIsServerless() - { - // Arrange - int wakeupPort = GetFreeTcpPort(); - ServerlessOptions options = new() - { - Mode = ServerlessMode.ServerlessInclude, - WakeupPort = wakeupPort, - }; - ServerlessWakeupServer server = new( - options, - NullLogger.Instance); - - // Act - await server.StartAsync(CancellationToken.None); - - try - { - using HttpClient httpClient = new(); - - // Assert - using HttpResponseMessage healthResponse = await httpClient.GetAsync( - $"http://127.0.0.1:{wakeupPort}/health"); - healthResponse.StatusCode.Should().Be(HttpStatusCode.OK); - - using HttpResponseMessage wakeupResponse = await httpClient.PostAsync( - $"http://127.0.0.1:{wakeupPort}/wakeup", - new ByteArrayContent([])); - wakeupResponse.StatusCode.Should().Be(HttpStatusCode.OK); - } - finally - { - await server.StopAsync(CancellationToken.None); - } + services.Count(descriptor => descriptor.ServiceType == typeof(IHostedService)).Should().Be(1); } sealed class FakeServerlessActivitiesClient : IServerlessActivitiesClient @@ -938,11 +901,4 @@ public EnvironmentVariableScope(string name, string? value) public void Dispose() => Environment.SetEnvironmentVariable(this.name, this.originalValue); } - - static int GetFreeTcpPort() - { - using TcpListener listener = new(IPAddress.Loopback, 0); - listener.Start(); - return ((IPEndPoint)listener.LocalEndpoint).Port; - } } From a60e43af028818d6aaa7646782b4af3ba37f3958 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 22 May 2026 11:45:12 -0700 Subject: [PATCH 28/81] Split serverless worker runtime options --- ...TaskSchedulerServerlessWorkerExtensions.cs | 18 ++++-- .../ServerlessActivityConfiguration.cs | 2 +- ...verlessActivityDeclarationHostedService.cs | 6 +- ...ActivityWorkerRegistrationHostedService.cs | 6 +- .../Worker/Serverless/ServerlessOptions.cs | 36 ----------- .../ServerlessWorkerRuntimeOptions.cs | 61 +++++++++++++++++++ .../ServerlessActivitiesTests.cs | 37 +++++++---- 7 files changed, 108 insertions(+), 58 deletions(-) create mode 100644 src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerRuntimeOptions.cs diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs index c6889ad7..b99d4103 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -76,10 +76,10 @@ public static IDurableTaskWorkerBuilder UseServerlessWorker(this IDurableTaskWor ConfigureDurableTaskSchedulerFromEnvironment(builder); builder.UseWorkItemFilters(); - builder.Services.AddOptions(builder.Name) + builder.Services.AddOptions(builder.Name) .PostConfigure>((options, schedulerOptions) => { - ApplyTaskHubDefault(options, schedulerOptions.Get(builder.Name).TaskHubName); + ApplyRuntimeTaskHubDefault(options, schedulerOptions.Get(builder.Name).TaskHubName); ApplyWorkerEnvironmentOverrides(options); }); @@ -129,10 +129,12 @@ static ServerlessActivityDeclarationHostedService CreateServerlessActivityDeclar { ServerlessOptions options = services.GetRequiredService>().Get(builderName); ILoggerFactory loggerFactory = services.GetRequiredService(); + ServerlessWorkerRuntimeOptions runtimeOptions = services.GetRequiredService>().Get(builderName); return new ServerlessActivityDeclarationHostedService( CreateServerlessActivitiesClient(services, builderName), options, + runtimeOptions, loggerFactory.CreateLogger()); } @@ -140,7 +142,7 @@ static ServerlessActivityWorkerRegistrationHostedService CreateServerlessActivit IServiceProvider services, string builderName) { - ServerlessOptions options = services.GetRequiredService>().Get(builderName); + ServerlessWorkerRuntimeOptions options = services.GetRequiredService>().Get(builderName); ILoggerFactory loggerFactory = services.GetRequiredService(); IHostApplicationLifetime? lifetime = services.GetService(); ServerlessActivityTracker activityTracker = services.GetRequiredService(); @@ -181,6 +183,14 @@ static void ApplyTaskHubDefault(ServerlessOptions options, string taskHubName) } } + static void ApplyRuntimeTaskHubDefault(ServerlessWorkerRuntimeOptions options, string taskHubName) + { + if (string.IsNullOrWhiteSpace(options.TaskHub) && !string.IsNullOrWhiteSpace(taskHubName)) + { + options.TaskHub = taskHubName; + } + } + static void ConfigureDurableTaskSchedulerFromEnvironment(IDurableTaskWorkerBuilder builder) { string? endpoint = Environment.GetEnvironmentVariable("DTS_ENDPOINT"); @@ -196,7 +206,7 @@ static void ConfigureDurableTaskSchedulerFromEnvironment(IDurableTaskWorkerBuild builder.UseDurableTaskScheduler(endpoint.Trim(), taskHub.Trim(), new DefaultAzureCredential()); } - static void ApplyWorkerEnvironmentOverrides(ServerlessOptions options) + static void ApplyWorkerEnvironmentOverrides(ServerlessWorkerRuntimeOptions options) { // Auto-detect worker mode from DTS_SUBSTRATE, which the backend injects when // launching a sandbox. This is the authoritative signal that this process is a sandbox worker. diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs index c9a3f5e7..89b40ef6 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs @@ -71,7 +71,7 @@ public static Proto.ServerlessActivityDeclaration BuildDeclaration(ServerlessOpt /// The activity handlers registered by the worker process. /// The worker start protocol message. public static Proto.ServerlessActivityWorkerMessage BuildWorkerStart( - ServerlessOptions options, + ServerlessWorkerRuntimeOptions options, IReadOnlyCollection registeredActivityNames) { Check.NotNull(options); diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs index 8c793388..4c3d8590 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs @@ -14,6 +14,7 @@ sealed class ServerlessActivityDeclarationHostedService : IHostedService { readonly IServerlessActivitiesClient client; readonly ServerlessOptions options; + readonly ServerlessWorkerRuntimeOptions? runtimeOptions; readonly ILogger logger; /// @@ -21,21 +22,24 @@ sealed class ServerlessActivityDeclarationHostedService : IHostedService /// /// The serverless activities client. /// The serverless options. + /// The optional serverless worker runtime options. /// The logger. public ServerlessActivityDeclarationHostedService( IServerlessActivitiesClient client, ServerlessOptions options, + ServerlessWorkerRuntimeOptions? runtimeOptions, ILogger logger) { this.client = Check.NotNull(client); this.options = Check.NotNull(options); + this.runtimeOptions = runtimeOptions; this.logger = Check.NotNull(logger); } /// public async Task StartAsync(CancellationToken cancellationToken) { - if (this.options.Mode == ServerlessMode.ServerlessInclude) + if (this.runtimeOptions?.Mode == ServerlessMode.ServerlessInclude) { return; } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs index ce506c10..fa00e396 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs @@ -16,7 +16,7 @@ sealed class ServerlessActivityWorkerRegistrationHostedService : IHostedService, { readonly object sync = new(); readonly IServerlessActivitiesClient client; - readonly ServerlessOptions options; + readonly ServerlessWorkerRuntimeOptions options; readonly IReadOnlyCollection registeredActivityNames; readonly ILogger logger; readonly IHostApplicationLifetime? lifetime; @@ -31,7 +31,7 @@ sealed class ServerlessActivityWorkerRegistrationHostedService : IHostedService, /// Initializes a new instance of the class. /// /// The serverless activities client. - /// The serverless options. + /// The serverless worker runtime options. /// The activity handlers registered by this worker process. /// The logger. /// The optional application lifetime used to stop the host when a non-retriable registration stream failure occurs. @@ -39,7 +39,7 @@ sealed class ServerlessActivityWorkerRegistrationHostedService : IHostedService, /// The optional random source used to jitter reconnect delays. public ServerlessActivityWorkerRegistrationHostedService( IServerlessActivitiesClient client, - ServerlessOptions options, + ServerlessWorkerRuntimeOptions options, IReadOnlyCollection registeredActivityNames, ILogger logger, IHostApplicationLifetime? lifetime = null, diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs index d30d88bc..6057a967 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs @@ -3,22 +3,6 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; -/// -/// Defines how a worker participates in serverless activity execution. -/// -internal enum ServerlessMode -{ - /// - /// The local worker declares serverless activities and excludes them from local execution. - /// - LocalExclude, - - /// - /// The worker runs inside serverless infrastructure and executes only serverless activities. - /// - ServerlessInclude, -} - /// /// Options for configuring serverless activity worker behavior. /// @@ -103,24 +87,4 @@ public sealed class ServerlessOptions /// Gets or sets the maximum number of concurrent activities expected from each serverless worker. /// public int MaxConcurrentActivities { get; set; } = 100; - - /// - /// Gets or sets the interval used to refresh live worker capacity while the registration stream is open. - /// - internal TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(2); - - /// - /// Gets or sets the initial delay before retrying a failed worker registration stream. - /// - internal TimeSpan WorkerRegistrationRetryInitialDelay { get; set; } = TimeSpan.FromSeconds(1); - - /// - /// Gets or sets the maximum delay before retrying a failed worker registration stream. - /// - internal TimeSpan WorkerRegistrationRetryMaxDelay { get; set; } = TimeSpan.FromSeconds(30); - - /// - /// Gets or sets the worker mode for serverless activity execution. Set automatically from the runtime environment. - /// - internal ServerlessMode Mode { get; set; } = ServerlessMode.LocalExclude; } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerRuntimeOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerRuntimeOptions.cs new file mode 100644 index 00000000..16852746 --- /dev/null +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerRuntimeOptions.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; + +/// +/// Defines how a worker participates in serverless activity execution. +/// +internal enum ServerlessMode +{ + /// + /// The worker is not running inside serverless infrastructure. + /// + LocalExclude, + + /// + /// The worker runs inside serverless infrastructure and executes only serverless activities. + /// + ServerlessInclude, +} + +/// +/// Internal runtime settings for a sandbox serverless worker process. +/// +internal sealed class ServerlessWorkerRuntimeOptions +{ + /// + /// Gets or sets the task hub used by serverless worker registration. + /// + public string TaskHub { get; set; } = string.Empty; + + /// + /// Gets or sets the worker profile ID used by serverless worker registration. + /// + public string WorkerProfileId { get; set; } = ServerlessOptions.DefaultWorkerProfileId; + + /// + /// Gets or sets the maximum number of concurrent activities expected from this serverless worker. + /// + public int MaxConcurrentActivities { get; set; } = 100; + + /// + /// Gets or sets the interval used to refresh live worker capacity while the registration stream is open. + /// + public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(2); + + /// + /// Gets or sets the initial delay before retrying a failed worker registration stream. + /// + public TimeSpan WorkerRegistrationRetryInitialDelay { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Gets or sets the maximum delay before retrying a failed worker registration stream. + /// + public TimeSpan WorkerRegistrationRetryMaxDelay { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Gets or sets the worker mode for serverless activity execution. Set automatically from the runtime environment. + /// + public ServerlessMode Mode { get; set; } = ServerlessMode.LocalExclude; +} diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index 97680223..c8c315f7 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Reflection; using FluentAssertions; using Grpc.Core; using Microsoft.DurableTask.Protobuf.Serverless; @@ -25,8 +26,19 @@ public void ServerlessDeclarationContract_DoesNotExposeRemovedOptions() typeof(ServerlessOptions).GetProperty("LaunchCommand").Should().BeNull(); typeof(ServerlessOptions).GetProperty("DeclarationRetryMaxAttempts").Should().BeNull(); typeof(ServerlessOptions).GetProperty("DeclarationRetryDelay").Should().BeNull(); - typeof(ServerlessOptions).GetProperty("HeartbeatInterval").Should().BeNull(); + typeof(ServerlessOptions).GetProperty( + "HeartbeatInterval", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); typeof(ServerlessOptions).GetProperty("WakeupPort").Should().BeNull(); + typeof(ServerlessOptions).GetProperty( + "WorkerRegistrationRetryInitialDelay", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); + typeof(ServerlessOptions).GetProperty( + "WorkerRegistrationRetryMaxDelay", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); + typeof(ServerlessOptions).GetProperty( + "Mode", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); typeof(ServerlessActivityDeclaration).GetProperty("LaunchCommand").Should().BeNull(); } @@ -53,6 +65,7 @@ public async Task ServerlessActivityDeclarationHostedService_SendsDeclarationPay ServerlessActivityDeclarationHostedService service = new( client, options, + runtimeOptions: null, NullLogger.Instance); // Act @@ -157,6 +170,7 @@ public async Task ServerlessActivityDeclarationHostedService_OmitsEntrypointAndC ServerlessActivityDeclarationHostedService service = new( client, options, + runtimeOptions: null, NullLogger.Instance); // Act @@ -181,6 +195,7 @@ public async Task ServerlessActivityDeclarationHostedService_SkipsDeclarationWhe ServerlessActivityDeclarationHostedService service = new( client, options, + runtimeOptions: null, NullLogger.Instance); // Act @@ -204,6 +219,7 @@ public async Task ServerlessActivityDeclarationHostedService_DoesNotRetryTransie ServerlessActivityDeclarationHostedService service = new( client, options, + runtimeOptions: null, NullLogger.Instance); // Act @@ -230,6 +246,7 @@ public async Task ServerlessActivityDeclarationHostedService_RejectsPrivatePullI ServerlessActivityDeclarationHostedService service = new( new FakeServerlessActivitiesClient(), options, + runtimeOptions: null, NullLogger.Instance); // Act @@ -251,7 +268,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWor try { - ServerlessOptions options = new() + ServerlessWorkerRuntimeOptions options = new() { Mode = ServerlessMode.ServerlessInclude, TaskHub = TaskHub, @@ -259,7 +276,6 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWor MaxConcurrentActivities = 3, HeartbeatInterval = TimeSpan.FromDays(1), }; - options.ActivityNames.Add("RemoteHello"); FakeServerlessActivitiesClient client = new(); ServerlessActivityWorkerRegistrationHostedService service = new( client, @@ -321,7 +337,7 @@ public void ServerlessActivityTracker_TracksInFlightActivityCount() public async Task ServerlessActivityWorkerRegistrationHostedService_SendsHeartbeatWithCurrentInFlightCount() { // Arrange - ServerlessOptions options = new() + ServerlessWorkerRuntimeOptions options = new() { Mode = ServerlessMode.ServerlessInclude, TaskHub = TaskHub, @@ -329,7 +345,6 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_SendsHeartbe MaxConcurrentActivities = 3, HeartbeatInterval = TimeSpan.FromMilliseconds(10), }; - options.ActivityNames.Add("RemoteHello"); FakeServerlessActivitiesClient client = new(); ServerlessActivityTracker activityTracker = new(); @@ -359,7 +374,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_SendsHeartbe public async Task ServerlessActivityWorkerRegistrationHostedService_ReopensSessionAfterTransientStreamFailure() { // Arrange - ServerlessOptions options = new() + ServerlessWorkerRuntimeOptions options = new() { Mode = ServerlessMode.ServerlessInclude, TaskHub = TaskHub, @@ -369,7 +384,6 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_ReopensSessi WorkerRegistrationRetryInitialDelay = TimeSpan.FromMilliseconds(10), WorkerRegistrationRetryMaxDelay = TimeSpan.FromMilliseconds(10), }; - options.ActivityNames.Add("RemoteHello"); FakeServerlessActivityWorkerSession failedSession = new() { ThrowOnWriteAttempt = 2 }; FakeServerlessActivityWorkerSession recoveredSession = new(); @@ -399,7 +413,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_ReopensSessi public async Task ServerlessActivityWorkerRegistrationHostedService_ReopensSessionAfterTerminalServerFailure() { // Arrange - ServerlessOptions options = new() + ServerlessWorkerRuntimeOptions options = new() { Mode = ServerlessMode.ServerlessInclude, TaskHub = TaskHub, @@ -409,7 +423,6 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_ReopensSessi WorkerRegistrationRetryInitialDelay = TimeSpan.FromMilliseconds(10), WorkerRegistrationRetryMaxDelay = TimeSpan.FromMilliseconds(10), }; - options.ActivityNames.Add("RemoteHello"); FakeServerlessActivityWorkerSession failedSession = new(); FakeServerlessActivityWorkerSession recoveredSession = new(); @@ -468,7 +481,7 @@ public void ServerlessActivityWorkerRegistrationHostedService_ComputeJitteredRec public async Task ServerlessActivityWorkerRegistrationHostedService_AppliesJitterToReconnectDelay() { // Arrange - ServerlessOptions options = new() + ServerlessWorkerRuntimeOptions options = new() { Mode = ServerlessMode.ServerlessInclude, TaskHub = TaskHub, @@ -478,7 +491,6 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_AppliesJitte WorkerRegistrationRetryInitialDelay = TimeSpan.FromDays(1), WorkerRegistrationRetryMaxDelay = TimeSpan.FromDays(1), }; - options.ActivityNames.Add("RemoteHello"); FakeServerlessActivityWorkerSession failedSession = new() { ThrowOnWriteAttempt = 2 }; FakeServerlessActivityWorkerSession recoveredSession = new(); @@ -507,7 +519,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_AppliesJitte public async Task ServerlessActivityWorkerRegistrationHostedService_StopAsync_DoesNotCompleteStreamWhileWriteIsInFlight() { // Arrange - ServerlessOptions options = new() + ServerlessWorkerRuntimeOptions options = new() { Mode = ServerlessMode.ServerlessInclude, TaskHub = TaskHub, @@ -515,7 +527,6 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_StopAsync_Do MaxConcurrentActivities = 3, HeartbeatInterval = TimeSpan.FromMilliseconds(10), }; - options.ActivityNames.Add("RemoteHello"); FakeServerlessActivityWorkerSession session = new() { BlockWriteAttempt = 2 }; FakeServerlessActivitiesClient client = new(); From ee477ac8230dd80571bd10dba4e6c67511f652ee Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 22 May 2026 13:18:44 -0700 Subject: [PATCH 29/81] Refactor serverless options and improve activity registration methods --- samples/serverless/main-app/Program.cs | 2 +- samples/serverless/main-app/main-app.csproj | 1 + ...TaskSchedulerServerlessWorkerExtensions.cs | 18 +++-- .../Worker/Serverless/ServerlessOptions.cs | 46 ++++++++++-- .../ServerlessActivitiesTests.cs | 72 ++++++++++++++++++- 5 files changed, 126 insertions(+), 13 deletions(-) diff --git a/samples/serverless/main-app/Program.cs b/samples/serverless/main-app/Program.cs index f5ca7c25..b589e6cb 100644 --- a/samples/serverless/main-app/Program.cs +++ b/samples/serverless/main-app/Program.cs @@ -49,7 +49,7 @@ options.Cpu = Environment.GetEnvironmentVariable("DTS_SERVERLESS_CPU") ?? "1000m"; options.Memory = Environment.GetEnvironmentVariable("DTS_SERVERLESS_MEMORY") ?? "2048Mi"; options.MaxConcurrentActivities = GetIntEnv("DTS_SERVERLESS_MAX_ACTIVITIES", 1); - options.ActivityNames.Add(ServerlessTaskNames.RemoteHello); + options.AddActivity(ServerlessTaskNames.RemoteHello); }); }); diff --git a/samples/serverless/main-app/main-app.csproj b/samples/serverless/main-app/main-app.csproj index d73515dc..f3987d2a 100644 --- a/samples/serverless/main-app/main-app.csproj +++ b/samples/serverless/main-app/main-app.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs index b99d4103..695408f1 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -193,17 +193,21 @@ static void ApplyRuntimeTaskHubDefault(ServerlessWorkerRuntimeOptions options, s static void ConfigureDurableTaskSchedulerFromEnvironment(IDurableTaskWorkerBuilder builder) { - string? endpoint = Environment.GetEnvironmentVariable("DTS_ENDPOINT"); - string? taskHub = Environment.GetEnvironmentVariable("DTS_TASK_HUB"); - if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(taskHub)) - { - return; - } + string endpoint = GetRequiredEnvironmentVariable("DTS_ENDPOINT"); + string taskHub = GetRequiredEnvironmentVariable("DTS_TASK_HUB"); // Private preview: DTS-owned sandbox workers authenticate with the injected // managed identity via DefaultAzureCredential. Revisit this if customer-owned // worker identities or non-default auth modes are introduced. - builder.UseDurableTaskScheduler(endpoint.Trim(), taskHub.Trim(), new DefaultAzureCredential()); + builder.UseDurableTaskScheduler(endpoint, taskHub, new DefaultAzureCredential()); + } + + static string GetRequiredEnvironmentVariable(string name) + { + string? value = Environment.GetEnvironmentVariable(name); + return string.IsNullOrWhiteSpace(value) + ? throw new InvalidOperationException($"{name} must be injected by DTS for serverless workers.") + : value.Trim(); } static void ApplyWorkerEnvironmentOverrides(ServerlessWorkerRuntimeOptions options) diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs index 6057a967..6f2660a3 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs @@ -4,7 +4,7 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; /// -/// Options for configuring serverless activity worker behavior. +/// Options for declaring serverless activities and the worker image DTS should start for them. /// public sealed class ServerlessOptions { @@ -14,12 +14,13 @@ public sealed class ServerlessOptions internal const string DefaultWorkerProfileId = "default"; /// - /// Gets the serverless activity names to declare or execute. + /// Gets the serverless activity names to declare. Remote workers report their registered + /// activities separately when they connect. /// public IList ActivityNames { get; } = new List(); /// - /// Gets or sets the task hub used by serverless activity calls. + /// Gets or sets the task hub where the serverless activity declaration is stored. /// public string TaskHub { get; set; } = string.Empty; @@ -69,7 +70,9 @@ public sealed class ServerlessOptions public string Memory { get; set; } = "2048Mi"; /// - /// Gets environment variables DTS should provide to serverless workers created from this declaration. + /// Gets custom environment variables DTS should provide to serverless workers created from this declaration. + /// DTS-owned runtime variables such as DTS_ENDPOINT, DTS_TASK_HUB, and + /// DTS_SANDBOX_ID are injected by the backend and should not be supplied here. /// public IDictionary EnvironmentVariables { get; } = new Dictionary(StringComparer.Ordinal); @@ -87,4 +90,39 @@ public sealed class ServerlessOptions /// Gets or sets the maximum number of concurrent activities expected from each serverless worker. /// public int MaxConcurrentActivities { get; set; } = 100; + + /// + /// Adds an activity name to the serverless declaration. + /// + /// The activity name to execute serverlessly. + /// The current options instance. + public ServerlessOptions AddActivity(string activityName) + { + if (string.IsNullOrWhiteSpace(activityName)) + { + throw new ArgumentException("Serverless activity name cannot be empty.", nameof(activityName)); + } + + this.ActivityNames.Add(activityName.Trim()); + return this; + } + + /// + /// Adds an activity type to the serverless declaration. + /// + /// The activity type to execute serverlessly. + /// The current options instance. + public ServerlessOptions AddActivity() + where TActivity : class, ITaskActivity + { + return this.AddActivity(GetTaskName(typeof(TActivity))); + } + + static string GetTaskName(Type type) + { + Check.NotNull(type); + return Attribute.GetCustomAttribute(type, typeof(DurableTaskAttribute)) is DurableTaskAttribute { Name.Name: not null and not "" } attr + ? attr.Name.Name + : type.Name; + } } diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index c8c315f7..f7d04b71 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -42,6 +42,21 @@ public void ServerlessDeclarationContract_DoesNotExposeRemovedOptions() typeof(ServerlessActivityDeclaration).GetProperty("LaunchCommand").Should().BeNull(); } + [Fact] + public void ServerlessOptions_AddActivity_AddsStringAndTypedActivityNames() + { + // Arrange + ServerlessOptions options = new(); + + // Act + options + .AddActivity(" RemoteHello ") + .AddActivity(); + + // Assert + options.ActivityNames.Should().Equal("RemoteHello", "TypedRemoteHello"); + } + [Fact] public async Task ServerlessActivityDeclarationHostedService_SendsDeclarationPayload() { @@ -55,7 +70,7 @@ public async Task ServerlessActivityDeclarationHostedService_SendsDeclarationPay Memory = "1024Mi", MaxConcurrentActivities = 7, }; - options.ActivityNames.Add("RemoteHello"); + options.AddActivity("RemoteHello"); options.EnvironmentVariables.Add("CUSTOM_SETTING", "enabled"); options.Entrypoint.Add("/usr/bin/tini"); options.Entrypoint.Add("--"); @@ -559,6 +574,8 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_StopAsync_Do public async Task DeclareServerlessActivities_ConfiguresLocalWorkerExclusionFilter() { // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); @@ -584,6 +601,8 @@ public async Task DeclareServerlessActivities_ConfiguresLocalWorkerExclusionFilt public async Task DeclareServerlessActivities_DoesNotConfigureFilterWhenActivityNamesAreEmpty() { // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); @@ -608,6 +627,8 @@ public async Task DeclareServerlessActivities_DoesNotConfigureFilterWhenActivity public async Task UseServerlessWorker_ConfiguresRegisteredActivityWorkerFilter() { // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); ServiceCollection services = new(); services.Configure( Options.DefaultName, @@ -633,6 +654,8 @@ public async Task UseServerlessWorker_ConfiguresRegisteredActivityWorkerFilter() public void UseServerlessWorker_DoesNotRegisterWakeupServerHostedService() { // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); @@ -645,6 +668,53 @@ public void UseServerlessWorker_DoesNotRegisterWakeupServerHostedService() services.Count(descriptor => descriptor.ServiceType == typeof(IHostedService)).Should().Be(1); } + [Fact] + public void UseServerlessWorker_MissingInjectedEndpoint_Throws() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", null); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + // Act + Action action = () => mockBuilder.Object.UseServerlessWorker(); + + // Assert + action.Should().Throw() + .WithMessage("DTS_ENDPOINT must be injected by DTS for serverless workers."); + } + + [Fact] + public void UseServerlessWorker_MissingInjectedTaskHub_Throws() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", null); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + // Act + Action action = () => mockBuilder.Object.UseServerlessWorker(); + + // Assert + action.Should().Throw() + .WithMessage("DTS_TASK_HUB must be injected by DTS for serverless workers."); + } + + [DurableTask("TypedRemoteHello")] + sealed class TypedRemoteHelloActivity : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, string input) + { + return Task.FromResult(input); + } + } + sealed class FakeServerlessActivitiesClient : IServerlessActivitiesClient { readonly Queue queuedSessions = new(); From 098983ad2945f51df93e10cb4624d6d9fae18873 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 22 May 2026 14:28:48 -0700 Subject: [PATCH 30/81] remove public pull --- .../ServerlessActivityConfiguration.cs | 6 ---- .../Worker/Serverless/ServerlessOptions.cs | 5 ---- src/Grpc/serverless_activities_service.proto | 1 - .../ServerlessActivitiesTests.cs | 28 ------------------- 4 files changed, 40 deletions(-) diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs index 89b40ef6..9e8bc2f6 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs @@ -127,11 +127,6 @@ public static Proto.ServerlessActivityWorkerMessage BuildWorkerHeartbeat(int act static Proto.ServerlessActivityImage BuildImage(ServerlessOptions options) { - if (!options.PublicPull) - { - throw new InvalidOperationException("Serverless activity images must be publicly pullable for private preview."); - } - string? imageRef = Coalesce( options.ContainerImage, BuildImageRef(options.RegistryServer, options.Repository, options.Tag, options.ImageDigest)); @@ -144,7 +139,6 @@ static Proto.ServerlessActivityImage BuildImage(ServerlessOptions options) return new Proto.ServerlessActivityImage { ImageRef = imageRef, - PublicPull = true, }; } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs index 6f2660a3..e3d07e11 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs @@ -54,11 +54,6 @@ public sealed class ServerlessOptions /// public string? ImageDigest { get; set; } - /// - /// Gets or sets a value indicating whether the image is publicly pullable. Private preview requires this to be true. - /// - public bool PublicPull { get; set; } = true; - /// /// Gets or sets the CPU quantity declared for each serverless sandbox. /// diff --git a/src/Grpc/serverless_activities_service.proto b/src/Grpc/serverless_activities_service.proto index f37cd62a..153d62db 100644 --- a/src/Grpc/serverless_activities_service.proto +++ b/src/Grpc/serverless_activities_service.proto @@ -68,7 +68,6 @@ message ServerlessActivityDeclaration { message ServerlessActivityImage { string image_ref = 1; - bool public_pull = 2; } message ServerlessActivityResources { diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index f7d04b71..f2d8b462 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -92,7 +92,6 @@ public async Task ServerlessActivityDeclarationHostedService_SendsDeclarationPay declaration.WorkerProfileId.Should().Be("profile-a"); declaration.ActivityNames.Should().Equal("RemoteHello"); declaration.Image.ImageRef.Should().Be("mcr.microsoft.com/durabletask/demo-worker:1.0"); - declaration.Image.PublicPull.Should().BeTrue(); declaration.Resources.Cpu.Should().Be("500m"); declaration.Resources.Memory.Should().Be("1024Mi"); declaration.EnvironmentVariables.Should().ContainKey("CUSTOM_SETTING").WhoseValue.Should().Be("enabled"); @@ -113,7 +112,6 @@ public async Task ServerlessActivitiesClientAdapter_SendsTaskHubMetadata() Image = new ServerlessActivityImage { ImageRef = "example.com/repo/worker:latest", - PublicPull = true, }, Resources = new ServerlessActivityResources { @@ -149,7 +147,6 @@ public async Task ServerlessActivitiesClientAdapter_CanRelyOnChannelTaskHubMetad Image = new ServerlessActivityImage { ImageRef = "example.com/repo/worker:latest", - PublicPull = true, }, Resources = new ServerlessActivityResources { @@ -247,31 +244,6 @@ await action.Should().ThrowAsync() client.Declarations.Should().BeEmpty(); } - [Fact] - public async Task ServerlessActivityDeclarationHostedService_RejectsPrivatePullImages() - { - // Arrange - ServerlessOptions options = new() - { - TaskHub = TaskHub, - ContainerImage = "example.com/repo/worker:latest", - PublicPull = false, - }; - options.ActivityNames.Add("RemoteHello"); - ServerlessActivityDeclarationHostedService service = new( - new FakeServerlessActivitiesClient(), - options, - runtimeOptions: null, - NullLogger.Instance); - - // Act - Func action = () => service.StartAsync(CancellationToken.None); - - // Assert - await action.Should().ThrowAsync() - .WithMessage("Serverless activity images must be publicly pullable for private preview."); - } - [Fact] public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWorkerMetadataWithRegisteredActivities() { From 44ee2e505acf33abab82b072ccf1d016563f6c7d Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 26 May 2026 20:41:43 -0700 Subject: [PATCH 31/81] Enable annotation-based serverless declarations Introduce annotation-driven serverless activity declarations and related plumbing. Add ServerlessWorkerProfileAttribute, ServerlessActivityAttribute, IServerlessWorkerProfile, and ServerlessActivityAnnotationResolver to discover profiles and activity markers in loaded assemblies. Replace DeclareServerlessActivities(configure) with EnableServerlessActivities(), change exclusion filter to use annotated activity names, and update scheduler wiring to configure endpoint/task hub without DefaultAzureCredential. Update ServerlessActivityDeclarationHostedService to accept and declare multiple ServerlessOptions. Update samples: add worker profile, declaration markers, many orchestrators and remote activity implementations, demo command parsing, and a skip-declaration flag. Extend tests to validate annotation resolution, hosted service registration, and worker runtime option behavior. --- samples/serverless/README.md | 17 ++- samples/serverless/main-app/Activities.cs | 60 ++++++++ samples/serverless/main-app/Orchestrators.cs | 103 +++++++++++++ samples/serverless/main-app/Program.cs | 75 +++++---- .../serverless/remote-worker/Activities.cs | 82 ++++++++++ samples/serverless/remote-worker/Program.cs | 6 + ...TaskSchedulerServerlessWorkerExtensions.cs | 47 ++---- .../Serverless/IServerlessWorkerProfile.cs | 16 ++ .../ServerlessActivityAnnotationResolver.cs | 144 ++++++++++++++++++ .../Serverless/ServerlessActivityAttribute.cs | 32 ++++ ...verlessActivityDeclarationHostedService.cs | 77 ++++++---- .../ServerlessWorkerProfileAttribute.cs | 27 ++++ .../ServerlessActivitiesTests.cs | 123 ++++++++++++--- 13 files changed, 698 insertions(+), 111 deletions(-) create mode 100644 src/Extensions/AzureManagedServerless/Worker/Serverless/IServerlessWorkerProfile.cs create mode 100644 src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs create mode 100644 src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAttribute.cs create mode 100644 src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerProfileAttribute.cs diff --git a/samples/serverless/README.md b/samples/serverless/README.md index d6ce29b1..336fa5b1 100644 --- a/samples/serverless/README.md +++ b/samples/serverless/README.md @@ -29,14 +29,14 @@ docker push $image ## Run a hello orchestration The main app uses `DefaultAzureCredential`; sign in with Azure CLI or configure another supported Azure identity before running it. +After pushing the remote worker image, set `ContainerImage` in +`DefaultServerlessWorkerProfile.Configure` to the pushed image reference. The +same method is where the sample declares CPU, memory, max concurrency, and the +customer environment variable used by the `env` demo command. ```powershell $env:DTS_ENDPOINT = "https://" $env:DTS_TASK_HUB = "" -$env:DTS_SERVERLESS_ACTIVITY_IMAGE = ".azurecr.io/dts-serverless-sample:" -$env:DTS_SERVERLESS_CPU = "1000m" -$env:DTS_SERVERLESS_MEMORY = "2048Mi" -$env:DTS_SERVERLESS_MAX_ACTIVITIES = "1" $env:DTS_SAMPLE_HELLO_INPUT = "serverless-sample" dotnet run --project .\samples\serverless\main-app\main-app.csproj @@ -51,6 +51,15 @@ Output: "hello from pid=: serverless-sample" Use the Durable Task Scheduler dashboard's Serverless Activities preview tab to inspect serverless activity runtimes and stream runtime logs. +To verify customer environment variable overrides end-to-end, run: + +```powershell +dotnet run --project .\samples\serverless\main-app\main-app.csproj -- env SERVERLESS_SAMPLE_MARKER +``` + +The result should include `SERVERLESS_SAMPLE_MARKER=serverless-dotnet-sample-marker` +from the worker profile declaration. + The remote worker image does not need customer-provided DTS runtime settings. DTS injects the scheduler endpoint, task hub, worker profile, capacity, substrate, and sandbox identifier when it starts the sandbox. The worker reports the diff --git a/samples/serverless/main-app/Activities.cs b/samples/serverless/main-app/Activities.cs index 73c5caee..8fac4197 100644 --- a/samples/serverless/main-app/Activities.cs +++ b/samples/serverless/main-app/Activities.cs @@ -1,10 +1,70 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.DurableTask.Worker.AzureManaged.Serverless; + namespace Microsoft.DurableTask.Samples.Serverless.MainApp; +[ServerlessWorkerProfile("default")] +internal sealed class DefaultServerlessWorkerProfile : IServerlessWorkerProfile +{ + public void Configure(ServerlessOptions options) + { + options.ContainerImage = "serverless-remote-worker:local"; + options.Cpu = "1000m"; + options.Memory = "2048Mi"; + options.MaxConcurrentActivities = 1; + options.EnvironmentVariables["SERVERLESS_SAMPLE_MARKER"] = "serverless-dotnet-sample-marker"; + } +} + internal static class ServerlessTaskNames { + public const string LocalEcho = "LocalEcho"; public const string RemoteHello = "RemoteHello"; + public const string RemoteDelay = "RemoteDelay"; + public const string RemoteEnv = "RemoteEnv"; + public const string RemoteFail = "RemoteFail"; + public const string RemoteFlaky = "RemoteFlaky"; + public const string RemoteIndex = "RemoteIndex"; + public const string RemoteCrash = "RemoteCrash"; public const string HelloOrchestrator = nameof(HelloOrchestrator); + public const string LongRunningOrchestrator = nameof(LongRunningOrchestrator); + public const string MixedLocalRemoteOrchestrator = nameof(MixedLocalRemoteOrchestrator); + public const string MultiActivityOrchestrator = nameof(MultiActivityOrchestrator); + public const string UndeclaredActivityOrchestrator = nameof(UndeclaredActivityOrchestrator); + public const string FanOutOrchestrator = nameof(FanOutOrchestrator); + public const string EnvOrchestrator = nameof(EnvOrchestrator); + public const string ExceptionOrchestrator = nameof(ExceptionOrchestrator); + public const string RetryOrchestrator = nameof(RetryOrchestrator); + public const string TimerOrchestrator = nameof(TimerOrchestrator); + public const string CrashOrchestrator = nameof(CrashOrchestrator); +} + +[DurableTask(ServerlessTaskNames.LocalEcho)] +internal sealed class LocalEchoActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, string input) + => Task.FromResult($"local:{input}"); } + +[ServerlessActivity("default", Name = ServerlessTaskNames.RemoteHello)] +internal sealed class RemoteHelloDeclaration; + +[ServerlessActivity("default", Name = ServerlessTaskNames.RemoteDelay)] +internal sealed class RemoteDelayDeclaration; + +[ServerlessActivity("default", Name = ServerlessTaskNames.RemoteEnv)] +internal sealed class RemoteEnvDeclaration; + +[ServerlessActivity("default", Name = ServerlessTaskNames.RemoteFail)] +internal sealed class RemoteFailDeclaration; + +[ServerlessActivity("default", Name = ServerlessTaskNames.RemoteFlaky)] +internal sealed class RemoteFlakyDeclaration; + +[ServerlessActivity("default", Name = ServerlessTaskNames.RemoteIndex)] +internal sealed class RemoteIndexDeclaration; + +[ServerlessActivity("default", Name = ServerlessTaskNames.RemoteCrash)] +internal sealed class RemoteCrashDeclaration; diff --git a/samples/serverless/main-app/Orchestrators.cs b/samples/serverless/main-app/Orchestrators.cs index b5fedd88..950a2444 100644 --- a/samples/serverless/main-app/Orchestrators.cs +++ b/samples/serverless/main-app/Orchestrators.cs @@ -14,3 +14,106 @@ public override async Task RunAsync(TaskOrchestrationContext context, st return remoteResult; } } + +[DurableTask(nameof(LongRunningOrchestrator))] +internal sealed class LongRunningOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string input) + { + string remoteResult = await context.CallActivityAsync(ServerlessTaskNames.RemoteDelay, input); + return remoteResult; + } +} + +[DurableTask(nameof(MixedLocalRemoteOrchestrator))] +internal sealed class MixedLocalRemoteOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string input) + { + string before = await context.CallActivityAsync(ServerlessTaskNames.LocalEcho, $"before:{input}"); + string remote = await context.CallActivityAsync(ServerlessTaskNames.RemoteHello, input); + string after = await context.CallActivityAsync(ServerlessTaskNames.LocalEcho, $"after:{input}"); + return $"{before}|{remote}|{after}"; + } +} + +[DurableTask(nameof(MultiActivityOrchestrator))] +internal sealed class MultiActivityOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string input) + { + string hello = await context.CallActivityAsync(ServerlessTaskNames.RemoteHello, input); + string env = await context.CallActivityAsync(ServerlessTaskNames.RemoteEnv, "SERVERLESS_SAMPLE_MARKER"); + string index = await context.CallActivityAsync(ServerlessTaskNames.RemoteIndex, "7"); + return $"{hello}|{env}|{index}"; + } +} + +[DurableTask(nameof(UndeclaredActivityOrchestrator))] +internal sealed class UndeclaredActivityOrchestrator : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, string input) => + context.CallActivityAsync("RemoteNotDeclared", input); +} + +[DurableTask(nameof(FanOutOrchestrator))] +internal sealed class FanOutOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string input) + { + string[] parts = input.Split('|', 2); + int count = int.TryParse(parts[0], out int parsedCount) && parsedCount > 0 ? parsedCount : 10; + string delay = parts.Length > 1 ? parts[1] : "0"; + + List> tasks = []; + for (int i = 0; i < count; i++) + { + tasks.Add(context.CallActivityAsync(ServerlessTaskNames.RemoteIndex, $"{i}|{delay}")); + } + + string[] results = await Task.WhenAll(tasks); + return string.Join(",", results.OrderBy(static item => int.Parse(item.Split(':')[0]))); + } +} + +[DurableTask(nameof(EnvOrchestrator))] +internal sealed class EnvOrchestrator : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, string input) => + context.CallActivityAsync(ServerlessTaskNames.RemoteEnv, input); +} + +[DurableTask(nameof(ExceptionOrchestrator))] +internal sealed class ExceptionOrchestrator : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, string input) => + context.CallActivityAsync(ServerlessTaskNames.RemoteFail, input); +} + +[DurableTask(nameof(RetryOrchestrator))] +internal sealed class RetryOrchestrator : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, string input) + { + TaskOptions options = new(new RetryPolicy(3, TimeSpan.FromSeconds(1))); + return context.CallActivityAsync(ServerlessTaskNames.RemoteFlaky, input, options); + } +} + +[DurableTask(nameof(TimerOrchestrator))] +internal sealed class TimerOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string input) + { + int seconds = int.TryParse(input, out int parsedSeconds) && parsedSeconds > 0 ? parsedSeconds : 5; + await context.CreateTimer(TimeSpan.FromSeconds(seconds), CancellationToken.None); + return await context.CallActivityAsync(ServerlessTaskNames.RemoteHello, $"timer:{seconds}"); + } +} + +[DurableTask(nameof(CrashOrchestrator))] +internal sealed class CrashOrchestrator : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, string input) => + context.CallActivityAsync(ServerlessTaskNames.RemoteCrash, input); +} diff --git a/samples/serverless/main-app/Program.cs b/samples/serverless/main-app/Program.cs index b589e6cb..46280891 100644 --- a/samples/serverless/main-app/Program.cs +++ b/samples/serverless/main-app/Program.cs @@ -15,10 +15,11 @@ string endpoint = GetRequiredEnvironmentVariable("DTS_ENDPOINT"); string taskHub = Environment.GetEnvironmentVariable("DTS_TASK_HUB") ?? "ServerlessPocHub"; -string workerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID") ?? "default"; -string serverlessActivityImage = Environment.GetEnvironmentVariable("DTS_SERVERLESS_ACTIVITY_IMAGE") - ?? "serverless-remote-worker:local"; -string helloInput = ParseHelloInput( +bool skipDeclaration = string.Equals( + Environment.GetEnvironmentVariable("DTS_SKIP_SERVERLESS_DECLARATION"), + "true", + StringComparison.OrdinalIgnoreCase); +DemoCommand command = ParseCommand( args, Environment.GetEnvironmentVariable("DTS_SAMPLE_HELLO_INPUT") ?? "serverless-sample"); TokenCredential credential = new DefaultAzureCredential(); @@ -41,16 +42,10 @@ options.Credential = credential; }); - workerBuilder.DeclareServerlessActivities(options => + if (!skipDeclaration) { - options.TaskHub = taskHub; - options.WorkerProfileId = workerProfileId; - options.ContainerImage = serverlessActivityImage; - options.Cpu = Environment.GetEnvironmentVariable("DTS_SERVERLESS_CPU") ?? "1000m"; - options.Memory = Environment.GetEnvironmentVariable("DTS_SERVERLESS_MEMORY") ?? "2048Mi"; - options.MaxConcurrentActivities = GetIntEnv("DTS_SERVERLESS_MAX_ACTIVITIES", 1); - options.AddActivity(ServerlessTaskNames.RemoteHello); - }); + workerBuilder.EnableServerlessActivities(); + } }); builder.Services.AddDurableTaskClient(clientBuilder => @@ -69,8 +64,8 @@ DurableTaskClient client = host.Services.GetRequiredService(); string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( - ServerlessTaskNames.HelloOrchestrator, - input: helloInput); + command.OrchestratorName, + input: command.Input); OrchestrationMetadata? result = await client.WaitForInstanceCompletionAsync( instanceId, getInputsAndOutputs: true); @@ -85,30 +80,48 @@ static string GetRequiredEnvironmentVariable(string name) => Environment.GetEnvironmentVariable(name) ?? throw new InvalidOperationException($"An environment variable named '{name}' is required."); -static int GetIntEnv(string name, int defaultValue) +static DemoCommand ParseCommand(string[] args, string defaultHelloInput) { - string? value = Environment.GetEnvironmentVariable(name); - if (string.IsNullOrWhiteSpace(value)) + if (args.Length == 0) { - return defaultValue; + return new DemoCommand(ServerlessTaskNames.HelloOrchestrator, defaultHelloInput); } - return int.TryParse(value, out int parsed) && parsed > 0 - ? parsed - : throw new InvalidOperationException($"Environment variable '{name}' must be a positive integer."); + string verb = args[0].ToLowerInvariant(); + return verb switch + { + "hello" => new DemoCommand( + ServerlessTaskNames.HelloOrchestrator, + args.Length > 1 ? args[1] : defaultHelloInput), + "long" => new DemoCommand( + ServerlessTaskNames.LongRunningOrchestrator, + $"{ParseLongRunningSeconds(args)}|{(args.Length > 2 ? args[2] : defaultHelloInput)}"), + "mixed" => new DemoCommand(ServerlessTaskNames.MixedLocalRemoteOrchestrator, args.Length > 1 ? args[1] : defaultHelloInput), + "multi" => new DemoCommand(ServerlessTaskNames.MultiActivityOrchestrator, args.Length > 1 ? args[1] : defaultHelloInput), + "undeclared" => new DemoCommand(ServerlessTaskNames.UndeclaredActivityOrchestrator, args.Length > 1 ? args[1] : defaultHelloInput), + "fanout" => new DemoCommand(ServerlessTaskNames.FanOutOrchestrator, $"{GetArgOrDefault(args, 1, "10")}|{GetArgOrDefault(args, 2, "0")}"), + "env" => new DemoCommand(ServerlessTaskNames.EnvOrchestrator, args.Length > 1 ? args[1] : "SERVERLESS_SAMPLE_MARKER"), + "fail" => new DemoCommand(ServerlessTaskNames.ExceptionOrchestrator, args.Length > 1 ? args[1] : defaultHelloInput), + "retry" => new DemoCommand(ServerlessTaskNames.RetryOrchestrator, args.Length > 1 ? args[1] : defaultHelloInput), + "timer" => new DemoCommand(ServerlessTaskNames.TimerOrchestrator, args.Length > 1 ? args[1] : "5"), + "crash" => new DemoCommand(ServerlessTaskNames.CrashOrchestrator, args.Length > 1 ? args[1] : defaultHelloInput), + _ => throw new InvalidOperationException("Supported commands: hello [name], long [seconds] [name], mixed [name], multi [name], undeclared [name], fanout [count] [delaySeconds], env [name], fail [name], retry [name], timer [seconds], crash [name]."), + }; } -static string ParseHelloInput(string[] args, string defaultHelloInput) +static string GetArgOrDefault(string[] args, int index, string defaultValue) => + args.Length > index ? args[index] : defaultValue; + +static int ParseLongRunningSeconds(string[] args) { - if (args.Length == 0) + if (args.Length <= 1) { - return defaultHelloInput; + return 900; } - string verb = args[0].ToLowerInvariant(); - return verb switch - { - "hello" => args.Length > 1 ? args[1] : defaultHelloInput, - _ => throw new InvalidOperationException("Supported commands: hello [name]."), - }; + return int.TryParse(args[1], out int seconds) && seconds > 0 + ? seconds + : throw new InvalidOperationException("The long command duration must be a positive integer number of seconds."); } + +sealed record DemoCommand(string OrchestratorName, string Input); diff --git a/samples/serverless/remote-worker/Activities.cs b/samples/serverless/remote-worker/Activities.cs index 51a576c1..e231a521 100644 --- a/samples/serverless/remote-worker/Activities.cs +++ b/samples/serverless/remote-worker/Activities.cs @@ -2,12 +2,94 @@ // Licensed under the MIT License. using Microsoft.DurableTask; +using System.Collections.Concurrent; namespace Microsoft.DurableTask.Samples.Serverless.RemoteWorker; +static class ActivityAttempts +{ + public static ConcurrentDictionary Attempts { get; } = new(StringComparer.Ordinal); +} + [DurableTask("RemoteHello")] internal sealed class RemoteHelloActivity : TaskActivity { public override Task RunAsync(TaskActivityContext context, string input) => Task.FromResult($"hello from {Environment.MachineName} pid={Environment.ProcessId}: {input}"); } + +[DurableTask("RemoteDelay")] +internal sealed class RemoteDelayActivity : TaskActivity +{ + public override async Task RunAsync(TaskActivityContext context, string input) + { + string[] parts = input.Split('|', 2); + int seconds = int.TryParse(parts[0], out int parsedSeconds) && parsedSeconds > 0 + ? parsedSeconds + : 300; + string label = parts.Length > 1 ? parts[1] : "long-running-serverless"; + + await Task.Delay(TimeSpan.FromSeconds(seconds), CancellationToken.None); + return $"delayed {seconds}s from {Environment.MachineName} pid={Environment.ProcessId}: {label}"; + } +} + +[DurableTask("RemoteEnv")] +internal sealed class RemoteEnvActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, string input) + { + string value = Environment.GetEnvironmentVariable(input) ?? ""; + return Task.FromResult($"{input}={value}"); + } +} + +[DurableTask("RemoteIndex")] +internal sealed class RemoteIndexActivity : TaskActivity +{ + public override async Task RunAsync(TaskActivityContext context, string input) + { + string[] parts = input.Split('|', 2); + int delaySeconds = parts.Length > 1 && int.TryParse(parts[1], out int parsedDelay) && parsedDelay > 0 + ? parsedDelay + : 0; + if (delaySeconds > 0) + { + await Task.Delay(TimeSpan.FromSeconds(delaySeconds), CancellationToken.None); + } + + return $"{parts[0]}:{Environment.MachineName}:{Environment.ProcessId}"; + } +} + +[DurableTask("RemoteFail")] +internal sealed class RemoteFailActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, string input) => + throw new InvalidOperationException($"RemoteFail requested: {input}"); +} + +[DurableTask("RemoteFlaky")] +internal sealed class RemoteFlakyActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, string input) + { + int attempt = ActivityAttempts.Attempts.AddOrUpdate(input, 1, static (_, current) => current + 1); + if (attempt == 1) + { + throw new InvalidOperationException($"RemoteFlaky first attempt failed: {input}"); + } + + return Task.FromResult($"flaky succeeded attempt={attempt}: {input}"); + } +} + +[DurableTask("RemoteCrash")] +internal sealed class RemoteCrashActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, string input) + { + Environment.Exit(42); + return Task.FromResult($"unreachable: {input}"); + } +} diff --git a/samples/serverless/remote-worker/Program.cs b/samples/serverless/remote-worker/Program.cs index b705fba5..f28b7990 100644 --- a/samples/serverless/remote-worker/Program.cs +++ b/samples/serverless/remote-worker/Program.cs @@ -21,6 +21,12 @@ workerBuilder.AddTasks(tasks => { tasks.AddActivity(); + tasks.AddActivity(); + tasks.AddActivity(); + tasks.AddActivity(); + tasks.AddActivity(); + tasks.AddActivity(); + tasks.AddActivity(); }); workerBuilder.UseServerlessWorker(); }); diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs index 695408f1..7014ccfd 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; -using Azure.Identity; using Grpc.Net.Client; using Microsoft.DurableTask.Protobuf.Serverless; using Microsoft.DurableTask.Worker.AzureManaged.Serverless; @@ -22,27 +21,17 @@ namespace Microsoft.DurableTask.Worker.AzureManaged; public static class DurableTaskSchedulerServerlessWorkerExtensions { /// - /// Declares serverless activities with DTS and excludes them from local execution. - /// Call this on the local coordinator worker — not on the sandbox worker binary. + /// Enables annotation-based serverless activity declarations with DTS and excludes annotated + /// serverless activities from local execution. /// /// The Durable Task worker builder to configure. - /// Callback to configure serverless activity behavior. /// The original builder, for call chaining. - public static IDurableTaskWorkerBuilder DeclareServerlessActivities( - this IDurableTaskWorkerBuilder builder, - Action configure) + public static IDurableTaskWorkerBuilder EnableServerlessActivities(this IDurableTaskWorkerBuilder builder) { Check.NotNull(builder); - Check.NotNull(configure); - - builder.Services.AddOptions(builder.Name) - .Configure(configure) - .PostConfigure>((options, schedulerOptions) => - ApplyTaskHubDefault(options, schedulerOptions.Get(builder.Name).TaskHubName)); builder.Services.AddOptions(builder.Name) - .PostConfigure>( - (filters, serverlessOptions) => ExcludeServerlessActivitiesFromLocalExecution(filters, serverlessOptions.Get(builder.Name))); + .PostConfigure(ExcludeAnnotatedServerlessActivitiesFromLocalExecution); builder.Services.AddSingleton(sp => CreateServerlessActivityDeclarationHostedService(sp, builder.Name)); return builder; @@ -55,7 +44,7 @@ public static IDurableTaskWorkerBuilder DeclareServerlessActivities( /// /// /// - /// This method is for separate worker binaries only. The coordinator uses + /// This method is for separate worker binaries only. The coordinator uses /// to declare and provision the serverless activity configuration. /// /// @@ -105,9 +94,9 @@ public static IDurableTaskWorkerBuilder UseServerlessWorker(this IDurableTaskWor return builder; } - static void ExcludeServerlessActivitiesFromLocalExecution(DurableTaskWorkerWorkItemFilters filters, ServerlessOptions options) + static void ExcludeAnnotatedServerlessActivitiesFromLocalExecution(DurableTaskWorkerWorkItemFilters filters) { - string[] activityNames = ServerlessActivityConfiguration.ResolveActivityNames(options.ActivityNames); + string[] activityNames = ServerlessActivityAnnotationResolver.ResolveActivityNames(); if (activityNames.Length == 0) { return; @@ -127,13 +116,13 @@ static ServerlessActivityDeclarationHostedService CreateServerlessActivityDeclar IServiceProvider services, string builderName) { - ServerlessOptions options = services.GetRequiredService>().Get(builderName); ILoggerFactory loggerFactory = services.GetRequiredService(); ServerlessWorkerRuntimeOptions runtimeOptions = services.GetRequiredService>().Get(builderName); + DurableTaskSchedulerWorkerOptions schedulerOptions = services.GetRequiredService>().Get(builderName); return new ServerlessActivityDeclarationHostedService( CreateServerlessActivitiesClient(services, builderName), - options, + ServerlessActivityAnnotationResolver.Resolve(schedulerOptions.TaskHubName), runtimeOptions, loggerFactory.CreateLogger()); } @@ -175,14 +164,6 @@ static ServerlessActivitiesClientAdapter CreateServerlessActivitiesClient(IServi throw new InvalidOperationException("Azure Managed serverless activities require a configured gRPC channel or call invoker."); } - static void ApplyTaskHubDefault(ServerlessOptions options, string taskHubName) - { - if (string.IsNullOrWhiteSpace(options.TaskHub) && !string.IsNullOrWhiteSpace(taskHubName)) - { - options.TaskHub = taskHubName; - } - } - static void ApplyRuntimeTaskHubDefault(ServerlessWorkerRuntimeOptions options, string taskHubName) { if (string.IsNullOrWhiteSpace(options.TaskHub) && !string.IsNullOrWhiteSpace(taskHubName)) @@ -196,10 +177,12 @@ static void ConfigureDurableTaskSchedulerFromEnvironment(IDurableTaskWorkerBuild string endpoint = GetRequiredEnvironmentVariable("DTS_ENDPOINT"); string taskHub = GetRequiredEnvironmentVariable("DTS_TASK_HUB"); - // Private preview: DTS-owned sandbox workers authenticate with the injected - // managed identity via DefaultAzureCredential. Revisit this if customer-owned - // worker identities or non-default auth modes are introduced. - builder.UseDurableTaskScheduler(endpoint, taskHub, new DefaultAzureCredential()); + builder.UseDurableTaskScheduler(options => + { + options.EndpointAddress = endpoint; + options.TaskHubName = taskHub; + options.AllowInsecureCredentials = true; + }); } static string GetRequiredEnvironmentVariable(string name) diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/IServerlessWorkerProfile.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/IServerlessWorkerProfile.cs new file mode 100644 index 00000000..69588436 --- /dev/null +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/IServerlessWorkerProfile.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; + +/// +/// Configures a serverless worker profile declaration. +/// +public interface IServerlessWorkerProfile +{ + /// + /// Configures the serverless worker profile declaration options. + /// + /// The declaration options to configure. + void Configure(ServerlessOptions options); +} diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs new file mode 100644 index 00000000..46ce4bc3 --- /dev/null +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; + +namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; + +/// +/// Resolves serverless worker profile and activity annotations from loaded assemblies. +/// +static class ServerlessActivityAnnotationResolver +{ + /// + /// Resolves annotated serverless declarations for the specified task hub. + /// + /// The task hub name. + /// The resolved serverless declaration options. + public static IReadOnlyList Resolve(string taskHub) + { + string normalizedTaskHub = string.IsNullOrWhiteSpace(taskHub) + ? throw new InvalidOperationException("Serverless activity declaration requires a task hub name.") + : taskHub.Trim(); + + Dictionary profiles = new(StringComparer.Ordinal); + Dictionary activityOwners = new(StringComparer.Ordinal); + + foreach (Type type in GetCandidateTypes()) + { + if (type.GetCustomAttribute() is { } profile) + { + if (profiles.ContainsKey(profile.WorkerProfileId)) + { + throw new InvalidOperationException($"Serverless worker profile '{profile.WorkerProfileId}' is declared more than once."); + } + + profiles.Add(profile.WorkerProfileId, CreateOptions(normalizedTaskHub, profile, type)); + } + } + + foreach (Type type in GetCandidateTypes()) + { + if (type.GetCustomAttribute() is not { } activity) + { + continue; + } + + if (!profiles.TryGetValue(activity.WorkerProfileId, out ServerlessOptions? options)) + { + throw new InvalidOperationException($"Serverless activity '{type.FullName}' references undeclared worker profile '{activity.WorkerProfileId}'."); + } + + string activityName = GetTaskName(type, activity); + if (activityOwners.TryGetValue(activityName, out string? existingProfile)) + { + throw new InvalidOperationException($"Serverless activity '{activityName}' is assigned to both worker profile '{existingProfile}' and '{activity.WorkerProfileId}'."); + } + + activityOwners.Add(activityName, activity.WorkerProfileId); + options.ActivityNames.Add(activityName); + } + + return profiles.Values + .Where(static options => options.ActivityNames.Count > 0) + .ToArray(); + } + + /// + /// Resolves annotated serverless activity names. + /// + /// The resolved activity names. + public static string[] ResolveActivityNames() => Resolve(taskHub: "annotation-scan") + .SelectMany(static options => options.ActivityNames) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + static ServerlessOptions CreateOptions(string taskHub, ServerlessWorkerProfileAttribute profile, Type profileType) + { + ServerlessOptions options = new() + { + TaskHub = taskHub, + WorkerProfileId = profile.WorkerProfileId, + }; + + ConfigureProfile(profileType, options); + + return options; + } + + static void ConfigureProfile(Type profileType, ServerlessOptions options) + { + if (!typeof(IServerlessWorkerProfile).IsAssignableFrom(profileType)) + { + return; + } + + object? instance = Activator.CreateInstance(profileType, nonPublic: true) + ?? throw new InvalidOperationException($"Serverless worker profile '{profileType.FullName}' could not be created."); + ((IServerlessWorkerProfile)instance).Configure(options); + } + + static IEnumerable GetCandidateTypes() + { + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + if (assembly.IsDynamic) + { + continue; + } + + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + types = ex.Types.Where(static type => type is not null).Cast().ToArray(); + } + + foreach (Type type in types) + { + yield return type; + } + } + } + + static string GetTaskName(Type type, ServerlessActivityAttribute activity) + { + Check.NotNull(type); + if (!string.IsNullOrWhiteSpace(activity.Name)) + { + return activity.Name.Trim(); + } + + if (!typeof(ITaskActivity).IsAssignableFrom(type)) + { + throw new InvalidOperationException($"Serverless activity declaration marker '{type.FullName}' must specify {nameof(ServerlessActivityAttribute.Name)} or implement {nameof(ITaskActivity)}."); + } + + return Attribute.GetCustomAttribute(type, typeof(DurableTaskAttribute)) is DurableTaskAttribute { Name.Name: not null and not "" } attr + ? attr.Name.Name + : type.Name; + } +} diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAttribute.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAttribute.cs new file mode 100644 index 00000000..386aaed9 --- /dev/null +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAttribute.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; + +/// +/// Marks an activity as serverless and associates it with a serverless worker profile. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class ServerlessActivityAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The worker profile ID that owns this activity. + public ServerlessActivityAttribute(string workerProfileId) + { + this.WorkerProfileId = string.IsNullOrWhiteSpace(workerProfileId) + ? throw new ArgumentException("Serverless activity worker profile ID cannot be empty.", nameof(workerProfileId)) + : workerProfileId.Trim(); + } + + /// + /// Gets the worker profile ID that owns this activity. + /// + public string WorkerProfileId { get; } + + /// + /// Gets or sets the activity name when this attribute is applied to a declaration marker class. + /// + public string? Name { get; set; } +} diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs index 4c3d8590..6ebc925e 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs @@ -13,7 +13,7 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; sealed class ServerlessActivityDeclarationHostedService : IHostedService { readonly IServerlessActivitiesClient client; - readonly ServerlessOptions options; + readonly IReadOnlyList declarations; readonly ServerlessWorkerRuntimeOptions? runtimeOptions; readonly ILogger logger; @@ -21,21 +21,37 @@ sealed class ServerlessActivityDeclarationHostedService : IHostedService /// Initializes a new instance of the class. /// /// The serverless activities client. - /// The serverless options. + /// The serverless declaration options. /// The optional serverless worker runtime options. /// The logger. public ServerlessActivityDeclarationHostedService( IServerlessActivitiesClient client, - ServerlessOptions options, + IReadOnlyList declarations, ServerlessWorkerRuntimeOptions? runtimeOptions, ILogger logger) { this.client = Check.NotNull(client); - this.options = Check.NotNull(options); + this.declarations = Check.NotNull(declarations); this.runtimeOptions = runtimeOptions; this.logger = Check.NotNull(logger); } + /// + /// Initializes a new instance of the class. + /// + /// The serverless activities client. + /// The serverless declaration options. + /// The optional serverless worker runtime options. + /// The logger. + public ServerlessActivityDeclarationHostedService( + IServerlessActivitiesClient client, + ServerlessOptions declaration, + ServerlessWorkerRuntimeOptions? runtimeOptions, + ILogger logger) + : this(client, [declaration], runtimeOptions, logger) + { + } + /// public async Task StartAsync(CancellationToken cancellationToken) { @@ -44,33 +60,42 @@ public async Task StartAsync(CancellationToken cancellationToken) return; } - string[] activityNames = ServerlessActivityConfiguration.ResolveActivityNames(this.options.ActivityNames); - if (activityNames.Length == 0) + if (this.declarations.Count == 0) { - Logs.NoServerlessActivitiesForDeclaration(this.logger, this.options.TaskHub); + Logs.NoServerlessActivitiesForDeclaration(this.logger, string.Empty); return; } - Proto.ServerlessActivityDeclaration declaration = ServerlessActivityConfiguration.BuildDeclaration( - this.options, - activityNames); - try - { - await this.client.DeclareServerlessActivitiesAsync( - declaration, - this.options.TaskHub, - cancellationToken).ConfigureAwait(false); - Logs.ServerlessActivitiesDeclared( - this.logger, - this.options.TaskHub, - declaration.WorkerProfileId, - declaration.ActivityNames.Count, - declaration.Image?.ImageRef ?? string.Empty); - } - catch (Exception ex) + foreach (ServerlessOptions options in this.declarations) { - Logs.ServerlessActivityDeclarationFailed(this.logger, ex, this.options.TaskHub); - throw; + string[] activityNames = ServerlessActivityConfiguration.ResolveActivityNames(options.ActivityNames); + if (activityNames.Length == 0) + { + Logs.NoServerlessActivitiesForDeclaration(this.logger, options.TaskHub); + continue; + } + + Proto.ServerlessActivityDeclaration declaration = ServerlessActivityConfiguration.BuildDeclaration( + options, + activityNames); + try + { + await this.client.DeclareServerlessActivitiesAsync( + declaration, + options.TaskHub, + cancellationToken).ConfigureAwait(false); + Logs.ServerlessActivitiesDeclared( + this.logger, + options.TaskHub, + declaration.WorkerProfileId, + declaration.ActivityNames.Count, + declaration.Image?.ImageRef ?? string.Empty); + } + catch (Exception ex) + { + Logs.ServerlessActivityDeclarationFailed(this.logger, ex, options.TaskHub); + throw; + } } } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerProfileAttribute.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerProfileAttribute.cs new file mode 100644 index 00000000..96162721 --- /dev/null +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerProfileAttribute.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; + +/// +/// Declares a serverless worker profile that DTS can start for annotated activities. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class ServerlessWorkerProfileAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The worker profile ID. + public ServerlessWorkerProfileAttribute(string workerProfileId) + { + this.WorkerProfileId = string.IsNullOrWhiteSpace(workerProfileId) + ? throw new ArgumentException("Serverless worker profile ID cannot be empty.", nameof(workerProfileId)) + : workerProfileId.Trim(); + } + + /// + /// Gets the worker profile ID. + /// + public string WorkerProfileId { get; } +} diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index f2d8b462..d16fb57a 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -543,7 +543,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_StopAsync_Do } [Fact] - public async Task DeclareServerlessActivities_ConfiguresLocalWorkerExclusionFilter() + public async Task EnableServerlessActivities_ConfiguresLocalWorkerExclusionFilterFromAnnotations() { // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); @@ -554,23 +554,18 @@ public async Task DeclareServerlessActivities_ConfiguresLocalWorkerExclusionFilt mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - mockBuilder.Object.DeclareServerlessActivities(options => - { - options.TaskHub = TaskHub; - options.ContainerImage = "example.com/repo/worker:latest"; - options.ActivityNames.Add("RemoteHello"); - }); + mockBuilder.Object.EnableServerlessActivities(); await using ServiceProvider provider = services.BuildServiceProvider(); DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); // Assert - filters.ExcludedActivities.Select(filter => filter.Name).Should().Equal("RemoteHello"); + filters.ExcludedActivities.Select(filter => filter.Name).Should().Contain("AnnotatedRemoteHello"); filters.Activities.Should().BeEmpty(); } [Fact] - public async Task DeclareServerlessActivities_DoesNotConfigureFilterWhenActivityNamesAreEmpty() + public async Task EnableServerlessActivities_RegistersDeclarationHostedService() { // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); @@ -581,18 +576,49 @@ public async Task DeclareServerlessActivities_DoesNotConfigureFilterWhenActivity mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - mockBuilder.Object.DeclareServerlessActivities(options => - { - options.TaskHub = TaskHub; - options.ContainerImage = "example.com/repo/worker:latest"; - }); + mockBuilder.Object.EnableServerlessActivities(); - await using ServiceProvider provider = services.BuildServiceProvider(); - DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); + // Assert + services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IHostedService)); + } + + [Fact] + public void ServerlessActivityAnnotationResolver_UsesWorkerProfileConfigureForDeclarationOptions() + { + // Arrange + using EnvironmentVariableScope image = new("DTS_SERVERLESS_ACTIVITY_IMAGE", "example.com/not-used:latest"); + using EnvironmentVariableScope cpu = new("DTS_SERVERLESS_CPU", "2000m"); + using EnvironmentVariableScope memory = new("DTS_SERVERLESS_MEMORY", "4096Mi"); + using EnvironmentVariableScope maxActivities = new("DTS_SERVERLESS_MAX_ACTIVITIES", "99"); + + // Act + ServerlessOptions options = ServerlessActivityAnnotationResolver.Resolve(TaskHub) + .Single(options => options.WorkerProfileId == "annotated-profile"); + ServerlessActivityDeclaration declaration = ServerlessActivityConfiguration.BuildDeclaration( + options, + ServerlessActivityConfiguration.ResolveActivityNames(options.ActivityNames)); // Assert - filters.ExcludedActivities.Should().BeEmpty(); - filters.Activities.Should().BeEmpty(); + declaration.WorkerProfileId.Should().Be("annotated-profile"); + declaration.ActivityNames.Should().Equal("AnnotatedRemoteHello"); + declaration.Image.ImageRef.Should().Be("example.com/repo/annotated-worker:latest"); + declaration.Resources.Cpu.Should().Be("500m"); + declaration.Resources.Memory.Should().Be("1024Mi"); + declaration.MaxConcurrentActivities.Should().Be(4); + declaration.EnvironmentVariables.Should().ContainKey("CUSTOM_ENV").WhoseValue.Should().Be("configured-value"); + declaration.Entrypoint.Should().BeEmpty(); + declaration.Cmd.Should().BeEmpty(); + } + + [Fact] + public void ServerlessActivityAnnotationResolver_AllowsDeclarationMarkerClassWithExplicitName() + { + // Act + ServerlessOptions options = ServerlessActivityAnnotationResolver.Resolve(TaskHub) + .Single(options => options.WorkerProfileId == "marker-profile"); + + // Assert + options.ActivityNames.Should().Equal("MarkerRemoteHello"); } [Fact] @@ -622,6 +648,32 @@ public async Task UseServerlessWorker_ConfiguresRegisteredActivityWorkerFilter() filters.Entities.Should().BeEmpty(); } + [Fact] + public async Task UseServerlessWorker_ConfiguresSchedulerWithoutCredential() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + // Act + mockBuilder.Object.UseServerlessWorker(); + + await using ServiceProvider provider = services.BuildServiceProvider(); + DurableTaskSchedulerWorkerOptions options = provider + .GetRequiredService>() + .Get(Options.DefaultName); + + // Assert + options.EndpointAddress.Should().Be("https://example.scheduler"); + options.TaskHubName.Should().Be(TaskHub); + options.Credential.Should().BeNull(); + options.AllowInsecureCredentials.Should().BeTrue(); + } + [Fact] public void UseServerlessWorker_DoesNotRegisterWakeupServerHostedService() { @@ -687,6 +739,41 @@ public override Task RunAsync(TaskActivityContext context, string input) } } + [ServerlessWorkerProfile("annotated-profile")] + sealed class AnnotatedWorkerProfile : IServerlessWorkerProfile + { + public void Configure(ServerlessOptions options) + { + options.ContainerImage = "example.com/repo/annotated-worker:latest"; + options.Cpu = "500m"; + options.Memory = "1024Mi"; + options.MaxConcurrentActivities = 4; + options.EnvironmentVariables["CUSTOM_ENV"] = "configured-value"; + } + } + + [DurableTask("AnnotatedRemoteHello")] + [ServerlessActivity("annotated-profile")] + sealed class AnnotatedRemoteHelloActivity : TaskActivity + { + public override Task RunAsync(TaskActivityContext context, string input) + { + return Task.FromResult(input); + } + } + + [ServerlessWorkerProfile("marker-profile")] + sealed class MarkerWorkerProfile : IServerlessWorkerProfile + { + public void Configure(ServerlessOptions options) + { + options.ContainerImage = "example.com/repo/marker-worker:latest"; + } + } + + [ServerlessActivity("marker-profile", Name = "MarkerRemoteHello")] + sealed class MarkerRemoteHelloDeclaration; + sealed class FakeServerlessActivitiesClient : IServerlessActivitiesClient { readonly Queue queuedSessions = new(); From 7188bb8a5825c7e16c060a6d4f3393e3793a95e6 Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 26 May 2026 20:49:05 -0700 Subject: [PATCH 32/81] Simplify serverless sample; move worker profile Rework the serverless sample to simplify activities and configuration. Move DefaultServerlessWorkerProfile into a new main-app/WorkerProfiles.cs and update README to reference it. Rename LocalEcho to LocalHello and change its output format; have the HelloOrchestrator call the local activity and concatenate local and remote results. Simplify main-app Program: streamline input handling, always enable serverless activities, and directly schedule the Hello orchestrator. Trim remote worker to a single RemoteHello activity that returns the scheduler-injected marker from env, and update the remote worker Program to only register that activity. --- samples/serverless/README.md | 17 +-- samples/serverless/main-app/Activities.cs | 55 +-------- samples/serverless/main-app/Orchestrators.cs | 106 +----------------- samples/serverless/main-app/Program.cs | 64 +---------- samples/serverless/main-app/WorkerProfiles.cs | 19 ++++ .../serverless/remote-worker/Activities.cs | 83 +------------- samples/serverless/remote-worker/Program.cs | 6 - 7 files changed, 37 insertions(+), 313 deletions(-) create mode 100644 samples/serverless/main-app/WorkerProfiles.cs diff --git a/samples/serverless/README.md b/samples/serverless/README.md index 336fa5b1..bd37776f 100644 --- a/samples/serverless/README.md +++ b/samples/serverless/README.md @@ -30,9 +30,9 @@ docker push $image The main app uses `DefaultAzureCredential`; sign in with Azure CLI or configure another supported Azure identity before running it. After pushing the remote worker image, set `ContainerImage` in -`DefaultServerlessWorkerProfile.Configure` to the pushed image reference. The -same method is where the sample declares CPU, memory, max concurrency, and the -customer environment variable used by the `env` demo command. +`main-app/WorkerProfiles.cs` to the pushed image reference. The same profile +class declares CPU, memory, max concurrency, and a customer environment variable +that the remote hello activity echoes. ```powershell $env:DTS_ENDPOINT = "https://" @@ -46,20 +46,11 @@ Expected output includes the serverless activity result: ```text Runtime status: Completed -Output: "hello from pid=: serverless-sample" +Output: "hello locally: serverless-sample; hello remotely from pid=: serverless-sample; marker=serverless-dotnet-sample-marker" ``` Use the Durable Task Scheduler dashboard's Serverless Activities preview tab to inspect serverless activity runtimes and stream runtime logs. -To verify customer environment variable overrides end-to-end, run: - -```powershell -dotnet run --project .\samples\serverless\main-app\main-app.csproj -- env SERVERLESS_SAMPLE_MARKER -``` - -The result should include `SERVERLESS_SAMPLE_MARKER=serverless-dotnet-sample-marker` -from the worker profile declaration. - The remote worker image does not need customer-provided DTS runtime settings. DTS injects the scheduler endpoint, task hub, worker profile, capacity, substrate, and sandbox identifier when it starts the sandbox. The worker reports the diff --git a/samples/serverless/main-app/Activities.cs b/samples/serverless/main-app/Activities.cs index 8fac4197..ffbf5b30 100644 --- a/samples/serverless/main-app/Activities.cs +++ b/samples/serverless/main-app/Activities.cs @@ -5,66 +5,19 @@ namespace Microsoft.DurableTask.Samples.Serverless.MainApp; -[ServerlessWorkerProfile("default")] -internal sealed class DefaultServerlessWorkerProfile : IServerlessWorkerProfile -{ - public void Configure(ServerlessOptions options) - { - options.ContainerImage = "serverless-remote-worker:local"; - options.Cpu = "1000m"; - options.Memory = "2048Mi"; - options.MaxConcurrentActivities = 1; - options.EnvironmentVariables["SERVERLESS_SAMPLE_MARKER"] = "serverless-dotnet-sample-marker"; - } -} - internal static class ServerlessTaskNames { - public const string LocalEcho = "LocalEcho"; + public const string LocalHello = "LocalHello"; public const string RemoteHello = "RemoteHello"; - public const string RemoteDelay = "RemoteDelay"; - public const string RemoteEnv = "RemoteEnv"; - public const string RemoteFail = "RemoteFail"; - public const string RemoteFlaky = "RemoteFlaky"; - public const string RemoteIndex = "RemoteIndex"; - public const string RemoteCrash = "RemoteCrash"; public const string HelloOrchestrator = nameof(HelloOrchestrator); - public const string LongRunningOrchestrator = nameof(LongRunningOrchestrator); - public const string MixedLocalRemoteOrchestrator = nameof(MixedLocalRemoteOrchestrator); - public const string MultiActivityOrchestrator = nameof(MultiActivityOrchestrator); - public const string UndeclaredActivityOrchestrator = nameof(UndeclaredActivityOrchestrator); - public const string FanOutOrchestrator = nameof(FanOutOrchestrator); - public const string EnvOrchestrator = nameof(EnvOrchestrator); - public const string ExceptionOrchestrator = nameof(ExceptionOrchestrator); - public const string RetryOrchestrator = nameof(RetryOrchestrator); - public const string TimerOrchestrator = nameof(TimerOrchestrator); - public const string CrashOrchestrator = nameof(CrashOrchestrator); } -[DurableTask(ServerlessTaskNames.LocalEcho)] -internal sealed class LocalEchoActivity : TaskActivity +[DurableTask(ServerlessTaskNames.LocalHello)] +internal sealed class LocalHelloActivity : TaskActivity { public override Task RunAsync(TaskActivityContext context, string input) - => Task.FromResult($"local:{input}"); + => Task.FromResult($"hello locally: {input}"); } [ServerlessActivity("default", Name = ServerlessTaskNames.RemoteHello)] internal sealed class RemoteHelloDeclaration; - -[ServerlessActivity("default", Name = ServerlessTaskNames.RemoteDelay)] -internal sealed class RemoteDelayDeclaration; - -[ServerlessActivity("default", Name = ServerlessTaskNames.RemoteEnv)] -internal sealed class RemoteEnvDeclaration; - -[ServerlessActivity("default", Name = ServerlessTaskNames.RemoteFail)] -internal sealed class RemoteFailDeclaration; - -[ServerlessActivity("default", Name = ServerlessTaskNames.RemoteFlaky)] -internal sealed class RemoteFlakyDeclaration; - -[ServerlessActivity("default", Name = ServerlessTaskNames.RemoteIndex)] -internal sealed class RemoteIndexDeclaration; - -[ServerlessActivity("default", Name = ServerlessTaskNames.RemoteCrash)] -internal sealed class RemoteCrashDeclaration; diff --git a/samples/serverless/main-app/Orchestrators.cs b/samples/serverless/main-app/Orchestrators.cs index 950a2444..294046d5 100644 --- a/samples/serverless/main-app/Orchestrators.cs +++ b/samples/serverless/main-app/Orchestrators.cs @@ -10,110 +10,8 @@ internal sealed class HelloOrchestrator : TaskOrchestrator { public override async Task RunAsync(TaskOrchestrationContext context, string input) { + string localResult = await context.CallActivityAsync(ServerlessTaskNames.LocalHello, input); string remoteResult = await context.CallActivityAsync(ServerlessTaskNames.RemoteHello, input); - return remoteResult; + return $"{localResult}; {remoteResult}"; } } - -[DurableTask(nameof(LongRunningOrchestrator))] -internal sealed class LongRunningOrchestrator : TaskOrchestrator -{ - public override async Task RunAsync(TaskOrchestrationContext context, string input) - { - string remoteResult = await context.CallActivityAsync(ServerlessTaskNames.RemoteDelay, input); - return remoteResult; - } -} - -[DurableTask(nameof(MixedLocalRemoteOrchestrator))] -internal sealed class MixedLocalRemoteOrchestrator : TaskOrchestrator -{ - public override async Task RunAsync(TaskOrchestrationContext context, string input) - { - string before = await context.CallActivityAsync(ServerlessTaskNames.LocalEcho, $"before:{input}"); - string remote = await context.CallActivityAsync(ServerlessTaskNames.RemoteHello, input); - string after = await context.CallActivityAsync(ServerlessTaskNames.LocalEcho, $"after:{input}"); - return $"{before}|{remote}|{after}"; - } -} - -[DurableTask(nameof(MultiActivityOrchestrator))] -internal sealed class MultiActivityOrchestrator : TaskOrchestrator -{ - public override async Task RunAsync(TaskOrchestrationContext context, string input) - { - string hello = await context.CallActivityAsync(ServerlessTaskNames.RemoteHello, input); - string env = await context.CallActivityAsync(ServerlessTaskNames.RemoteEnv, "SERVERLESS_SAMPLE_MARKER"); - string index = await context.CallActivityAsync(ServerlessTaskNames.RemoteIndex, "7"); - return $"{hello}|{env}|{index}"; - } -} - -[DurableTask(nameof(UndeclaredActivityOrchestrator))] -internal sealed class UndeclaredActivityOrchestrator : TaskOrchestrator -{ - public override Task RunAsync(TaskOrchestrationContext context, string input) => - context.CallActivityAsync("RemoteNotDeclared", input); -} - -[DurableTask(nameof(FanOutOrchestrator))] -internal sealed class FanOutOrchestrator : TaskOrchestrator -{ - public override async Task RunAsync(TaskOrchestrationContext context, string input) - { - string[] parts = input.Split('|', 2); - int count = int.TryParse(parts[0], out int parsedCount) && parsedCount > 0 ? parsedCount : 10; - string delay = parts.Length > 1 ? parts[1] : "0"; - - List> tasks = []; - for (int i = 0; i < count; i++) - { - tasks.Add(context.CallActivityAsync(ServerlessTaskNames.RemoteIndex, $"{i}|{delay}")); - } - - string[] results = await Task.WhenAll(tasks); - return string.Join(",", results.OrderBy(static item => int.Parse(item.Split(':')[0]))); - } -} - -[DurableTask(nameof(EnvOrchestrator))] -internal sealed class EnvOrchestrator : TaskOrchestrator -{ - public override Task RunAsync(TaskOrchestrationContext context, string input) => - context.CallActivityAsync(ServerlessTaskNames.RemoteEnv, input); -} - -[DurableTask(nameof(ExceptionOrchestrator))] -internal sealed class ExceptionOrchestrator : TaskOrchestrator -{ - public override Task RunAsync(TaskOrchestrationContext context, string input) => - context.CallActivityAsync(ServerlessTaskNames.RemoteFail, input); -} - -[DurableTask(nameof(RetryOrchestrator))] -internal sealed class RetryOrchestrator : TaskOrchestrator -{ - public override Task RunAsync(TaskOrchestrationContext context, string input) - { - TaskOptions options = new(new RetryPolicy(3, TimeSpan.FromSeconds(1))); - return context.CallActivityAsync(ServerlessTaskNames.RemoteFlaky, input, options); - } -} - -[DurableTask(nameof(TimerOrchestrator))] -internal sealed class TimerOrchestrator : TaskOrchestrator -{ - public override async Task RunAsync(TaskOrchestrationContext context, string input) - { - int seconds = int.TryParse(input, out int parsedSeconds) && parsedSeconds > 0 ? parsedSeconds : 5; - await context.CreateTimer(TimeSpan.FromSeconds(seconds), CancellationToken.None); - return await context.CallActivityAsync(ServerlessTaskNames.RemoteHello, $"timer:{seconds}"); - } -} - -[DurableTask(nameof(CrashOrchestrator))] -internal sealed class CrashOrchestrator : TaskOrchestrator -{ - public override Task RunAsync(TaskOrchestrationContext context, string input) => - context.CallActivityAsync(ServerlessTaskNames.RemoteCrash, input); -} diff --git a/samples/serverless/main-app/Program.cs b/samples/serverless/main-app/Program.cs index 46280891..084828c8 100644 --- a/samples/serverless/main-app/Program.cs +++ b/samples/serverless/main-app/Program.cs @@ -15,13 +15,9 @@ string endpoint = GetRequiredEnvironmentVariable("DTS_ENDPOINT"); string taskHub = Environment.GetEnvironmentVariable("DTS_TASK_HUB") ?? "ServerlessPocHub"; -bool skipDeclaration = string.Equals( - Environment.GetEnvironmentVariable("DTS_SKIP_SERVERLESS_DECLARATION"), - "true", - StringComparison.OrdinalIgnoreCase); -DemoCommand command = ParseCommand( - args, - Environment.GetEnvironmentVariable("DTS_SAMPLE_HELLO_INPUT") ?? "serverless-sample"); +string input = args.Length > 0 + ? args[0] + : Environment.GetEnvironmentVariable("DTS_SAMPLE_HELLO_INPUT") ?? "serverless-sample"; TokenCredential credential = new DefaultAzureCredential(); HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); @@ -42,10 +38,7 @@ options.Credential = credential; }); - if (!skipDeclaration) - { - workerBuilder.EnableServerlessActivities(); - } + workerBuilder.EnableServerlessActivities(); }); builder.Services.AddDurableTaskClient(clientBuilder => @@ -64,8 +57,8 @@ DurableTaskClient client = host.Services.GetRequiredService(); string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( - command.OrchestratorName, - input: command.Input); + ServerlessTaskNames.HelloOrchestrator, + input: input); OrchestrationMetadata? result = await client.WaitForInstanceCompletionAsync( instanceId, getInputsAndOutputs: true); @@ -80,48 +73,3 @@ static string GetRequiredEnvironmentVariable(string name) => Environment.GetEnvironmentVariable(name) ?? throw new InvalidOperationException($"An environment variable named '{name}' is required."); -static DemoCommand ParseCommand(string[] args, string defaultHelloInput) -{ - if (args.Length == 0) - { - return new DemoCommand(ServerlessTaskNames.HelloOrchestrator, defaultHelloInput); - } - - string verb = args[0].ToLowerInvariant(); - return verb switch - { - "hello" => new DemoCommand( - ServerlessTaskNames.HelloOrchestrator, - args.Length > 1 ? args[1] : defaultHelloInput), - "long" => new DemoCommand( - ServerlessTaskNames.LongRunningOrchestrator, - $"{ParseLongRunningSeconds(args)}|{(args.Length > 2 ? args[2] : defaultHelloInput)}"), - "mixed" => new DemoCommand(ServerlessTaskNames.MixedLocalRemoteOrchestrator, args.Length > 1 ? args[1] : defaultHelloInput), - "multi" => new DemoCommand(ServerlessTaskNames.MultiActivityOrchestrator, args.Length > 1 ? args[1] : defaultHelloInput), - "undeclared" => new DemoCommand(ServerlessTaskNames.UndeclaredActivityOrchestrator, args.Length > 1 ? args[1] : defaultHelloInput), - "fanout" => new DemoCommand(ServerlessTaskNames.FanOutOrchestrator, $"{GetArgOrDefault(args, 1, "10")}|{GetArgOrDefault(args, 2, "0")}"), - "env" => new DemoCommand(ServerlessTaskNames.EnvOrchestrator, args.Length > 1 ? args[1] : "SERVERLESS_SAMPLE_MARKER"), - "fail" => new DemoCommand(ServerlessTaskNames.ExceptionOrchestrator, args.Length > 1 ? args[1] : defaultHelloInput), - "retry" => new DemoCommand(ServerlessTaskNames.RetryOrchestrator, args.Length > 1 ? args[1] : defaultHelloInput), - "timer" => new DemoCommand(ServerlessTaskNames.TimerOrchestrator, args.Length > 1 ? args[1] : "5"), - "crash" => new DemoCommand(ServerlessTaskNames.CrashOrchestrator, args.Length > 1 ? args[1] : defaultHelloInput), - _ => throw new InvalidOperationException("Supported commands: hello [name], long [seconds] [name], mixed [name], multi [name], undeclared [name], fanout [count] [delaySeconds], env [name], fail [name], retry [name], timer [seconds], crash [name]."), - }; -} - -static string GetArgOrDefault(string[] args, int index, string defaultValue) => - args.Length > index ? args[index] : defaultValue; - -static int ParseLongRunningSeconds(string[] args) -{ - if (args.Length <= 1) - { - return 900; - } - - return int.TryParse(args[1], out int seconds) && seconds > 0 - ? seconds - : throw new InvalidOperationException("The long command duration must be a positive integer number of seconds."); -} - -sealed record DemoCommand(string OrchestratorName, string Input); diff --git a/samples/serverless/main-app/WorkerProfiles.cs b/samples/serverless/main-app/WorkerProfiles.cs new file mode 100644 index 00000000..9899df9c --- /dev/null +++ b/samples/serverless/main-app/WorkerProfiles.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Worker.AzureManaged.Serverless; + +namespace Microsoft.DurableTask.Samples.Serverless.MainApp; + +[ServerlessWorkerProfile("default")] +internal sealed class DefaultServerlessWorkerProfile : IServerlessWorkerProfile +{ + public void Configure(ServerlessOptions options) + { + options.ContainerImage = "serverless-remote-worker:local"; + options.Cpu = "1000m"; + options.Memory = "2048Mi"; + options.MaxConcurrentActivities = 1; + options.EnvironmentVariables["SERVERLESS_SAMPLE_MARKER"] = "serverless-dotnet-sample-marker"; + } +} \ No newline at end of file diff --git a/samples/serverless/remote-worker/Activities.cs b/samples/serverless/remote-worker/Activities.cs index e231a521..e5c58470 100644 --- a/samples/serverless/remote-worker/Activities.cs +++ b/samples/serverless/remote-worker/Activities.cs @@ -2,94 +2,15 @@ // Licensed under the MIT License. using Microsoft.DurableTask; -using System.Collections.Concurrent; namespace Microsoft.DurableTask.Samples.Serverless.RemoteWorker; -static class ActivityAttempts -{ - public static ConcurrentDictionary Attempts { get; } = new(StringComparer.Ordinal); -} - [DurableTask("RemoteHello")] internal sealed class RemoteHelloActivity : TaskActivity -{ - public override Task RunAsync(TaskActivityContext context, string input) - => Task.FromResult($"hello from {Environment.MachineName} pid={Environment.ProcessId}: {input}"); -} - -[DurableTask("RemoteDelay")] -internal sealed class RemoteDelayActivity : TaskActivity -{ - public override async Task RunAsync(TaskActivityContext context, string input) - { - string[] parts = input.Split('|', 2); - int seconds = int.TryParse(parts[0], out int parsedSeconds) && parsedSeconds > 0 - ? parsedSeconds - : 300; - string label = parts.Length > 1 ? parts[1] : "long-running-serverless"; - - await Task.Delay(TimeSpan.FromSeconds(seconds), CancellationToken.None); - return $"delayed {seconds}s from {Environment.MachineName} pid={Environment.ProcessId}: {label}"; - } -} - -[DurableTask("RemoteEnv")] -internal sealed class RemoteEnvActivity : TaskActivity -{ - public override Task RunAsync(TaskActivityContext context, string input) - { - string value = Environment.GetEnvironmentVariable(input) ?? ""; - return Task.FromResult($"{input}={value}"); - } -} - -[DurableTask("RemoteIndex")] -internal sealed class RemoteIndexActivity : TaskActivity -{ - public override async Task RunAsync(TaskActivityContext context, string input) - { - string[] parts = input.Split('|', 2); - int delaySeconds = parts.Length > 1 && int.TryParse(parts[1], out int parsedDelay) && parsedDelay > 0 - ? parsedDelay - : 0; - if (delaySeconds > 0) - { - await Task.Delay(TimeSpan.FromSeconds(delaySeconds), CancellationToken.None); - } - - return $"{parts[0]}:{Environment.MachineName}:{Environment.ProcessId}"; - } -} - -[DurableTask("RemoteFail")] -internal sealed class RemoteFailActivity : TaskActivity -{ - public override Task RunAsync(TaskActivityContext context, string input) => - throw new InvalidOperationException($"RemoteFail requested: {input}"); -} - -[DurableTask("RemoteFlaky")] -internal sealed class RemoteFlakyActivity : TaskActivity -{ - public override Task RunAsync(TaskActivityContext context, string input) - { - int attempt = ActivityAttempts.Attempts.AddOrUpdate(input, 1, static (_, current) => current + 1); - if (attempt == 1) - { - throw new InvalidOperationException($"RemoteFlaky first attempt failed: {input}"); - } - - return Task.FromResult($"flaky succeeded attempt={attempt}: {input}"); - } -} - -[DurableTask("RemoteCrash")] -internal sealed class RemoteCrashActivity : TaskActivity { public override Task RunAsync(TaskActivityContext context, string input) { - Environment.Exit(42); - return Task.FromResult($"unreachable: {input}"); + string marker = Environment.GetEnvironmentVariable("SERVERLESS_SAMPLE_MARKER") ?? ""; + return Task.FromResult($"hello remotely from {Environment.MachineName} pid={Environment.ProcessId}: {input}; marker={marker}"); } } diff --git a/samples/serverless/remote-worker/Program.cs b/samples/serverless/remote-worker/Program.cs index f28b7990..b705fba5 100644 --- a/samples/serverless/remote-worker/Program.cs +++ b/samples/serverless/remote-worker/Program.cs @@ -21,12 +21,6 @@ workerBuilder.AddTasks(tasks => { tasks.AddActivity(); - tasks.AddActivity(); - tasks.AddActivity(); - tasks.AddActivity(); - tasks.AddActivity(); - tasks.AddActivity(); - tasks.AddActivity(); }); workerBuilder.UseServerlessWorker(); }); From 5f431e343c2bde9a2cda93495396fdc4fc872368 Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 26 May 2026 20:51:54 -0700 Subject: [PATCH 33/81] Validate and resolve serverless activity annotations Replace ResolveActivityNames implementation to scan attribute-decorated types instead of relying on Resolve(taskHub). The new logic collects declared worker profiles, ensures profiles are unique, validates that activities reference declared profiles, and prevents the same activity name from being assigned to multiple profiles. Added unit test to assert ResolveActivityNames can run without triggering profile Configure calls and updated the annotated test profile with a ConfigureCallCount to verify no configure invocation occurs. --- .../ServerlessActivityAnnotationResolver.cs | 43 +++++++++++++++++-- .../ServerlessActivitiesTests.cs | 17 ++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs index 46ce4bc3..68a1b110 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs @@ -68,10 +68,45 @@ public static IReadOnlyList Resolve(string taskHub) /// Resolves annotated serverless activity names. /// /// The resolved activity names. - public static string[] ResolveActivityNames() => Resolve(taskHub: "annotation-scan") - .SelectMany(static options => options.ActivityNames) - .Distinct(StringComparer.Ordinal) - .ToArray(); + public static string[] ResolveActivityNames() + { + HashSet profiles = new(StringComparer.Ordinal); + Dictionary activityOwners = new(StringComparer.Ordinal); + + foreach (Type type in GetCandidateTypes()) + { + if (type.GetCustomAttribute() is { } profile) + { + if (!profiles.Add(profile.WorkerProfileId)) + { + throw new InvalidOperationException($"Serverless worker profile '{profile.WorkerProfileId}' is declared more than once."); + } + } + } + + foreach (Type type in GetCandidateTypes()) + { + if (type.GetCustomAttribute() is not { } activity) + { + continue; + } + + if (!profiles.Contains(activity.WorkerProfileId)) + { + throw new InvalidOperationException($"Serverless activity '{type.FullName}' references undeclared worker profile '{activity.WorkerProfileId}'."); + } + + string activityName = GetTaskName(type, activity); + if (activityOwners.TryGetValue(activityName, out string? existingProfile)) + { + throw new InvalidOperationException($"Serverless activity '{activityName}' is assigned to both worker profile '{existingProfile}' and '{activity.WorkerProfileId}'."); + } + + activityOwners.Add(activityName, activity.WorkerProfileId); + } + + return activityOwners.Keys.ToArray(); + } static ServerlessOptions CreateOptions(string taskHub, ServerlessWorkerProfileAttribute profile, Type profileType) { diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index d16fb57a..0332e00a 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -621,6 +621,20 @@ public void ServerlessActivityAnnotationResolver_AllowsDeclarationMarkerClassWit options.ActivityNames.Should().Equal("MarkerRemoteHello"); } + [Fact] + public void ServerlessActivityAnnotationResolver_ResolveActivityNames_DoesNotRequireTaskHubOrConfigureProfiles() + { + // Arrange + int before = AnnotatedWorkerProfile.ConfigureCallCount; + + // Act + string[] activityNames = ServerlessActivityAnnotationResolver.ResolveActivityNames(); + + // Assert + activityNames.Should().Contain(["AnnotatedRemoteHello", "MarkerRemoteHello"]); + AnnotatedWorkerProfile.ConfigureCallCount.Should().Be(before); + } + [Fact] public async Task UseServerlessWorker_ConfiguresRegisteredActivityWorkerFilter() { @@ -742,8 +756,11 @@ public override Task RunAsync(TaskActivityContext context, string input) [ServerlessWorkerProfile("annotated-profile")] sealed class AnnotatedWorkerProfile : IServerlessWorkerProfile { + public static int ConfigureCallCount { get; private set; } + public void Configure(ServerlessOptions options) { + ConfigureCallCount++; options.ContainerImage = "example.com/repo/annotated-worker:latest"; options.Cpu = "500m"; options.Memory = "1024Mi"; From 46ce48bc0d5b31782ce2987d0787f155208098e6 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 27 May 2026 00:10:19 -0700 Subject: [PATCH 34/81] Disallow EnableServerlessActivities in worker Add a runtime guard that throws when EnableServerlessActivities is called in a DTS serverless worker (DTS_SUBSTRATE == "Sandbox" or "AcaSessionPool"). Introduces a constant error message and ThrowIfServerlessWorkerRuntime, extracts IsServerlessWorkerSubstrate helper, and updates substrate-based mode detection to use it. Adds a unit test to verify the method throws and does not register hosted services when running in the serverless worker substrate. --- ...TaskSchedulerServerlessWorkerExtensions.cs | 21 ++++++++++++++++--- .../ServerlessActivitiesTests.cs | 19 +++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs index 7014ccfd..b80a0e3d 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -20,6 +20,10 @@ namespace Microsoft.DurableTask.Worker.AzureManaged; /// public static class DurableTaskSchedulerServerlessWorkerExtensions { + const string EnableServerlessActivitiesWorkerRuntimeErrorMessage = + "EnableServerlessActivities is for declaring serverless activities from the coordinator app. " + + "DTS serverless workers should use UseServerlessWorker instead."; + /// /// Enables annotation-based serverless activity declarations with DTS and excludes annotated /// serverless activities from local execution. @@ -29,6 +33,7 @@ public static class DurableTaskSchedulerServerlessWorkerExtensions public static IDurableTaskWorkerBuilder EnableServerlessActivities(this IDurableTaskWorkerBuilder builder) { Check.NotNull(builder); + ThrowIfServerlessWorkerRuntime(); builder.Services.AddOptions(builder.Name) .PostConfigure(ExcludeAnnotatedServerlessActivitiesFromLocalExecution); @@ -112,6 +117,14 @@ static void IncludeOnlyRegisteredActivities(DurableTaskWorkerWorkItemFilters fil filters.Entities = []; } + static void ThrowIfServerlessWorkerRuntime() + { + if (IsServerlessWorkerSubstrate(Environment.GetEnvironmentVariable("DTS_SUBSTRATE"))) + { + throw new InvalidOperationException(EnableServerlessActivitiesWorkerRuntimeErrorMessage); + } + } + static ServerlessActivityDeclarationHostedService CreateServerlessActivityDeclarationHostedService( IServiceProvider services, string builderName) @@ -197,9 +210,7 @@ static void ApplyWorkerEnvironmentOverrides(ServerlessWorkerRuntimeOptions optio { // Auto-detect worker mode from DTS_SUBSTRATE, which the backend injects when // launching a sandbox. This is the authoritative signal that this process is a sandbox worker. - string? substrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); - if (string.Equals(substrate, "Sandbox", StringComparison.OrdinalIgnoreCase) - || string.Equals(substrate, "AcaSessionPool", StringComparison.OrdinalIgnoreCase)) + if (IsServerlessWorkerSubstrate(Environment.GetEnvironmentVariable("DTS_SUBSTRATE"))) { options.Mode = ServerlessMode.ServerlessInclude; } @@ -221,6 +232,10 @@ static void ApplyWorkerProfileEnvironmentOverride(Action setWorkerProfil } } + static bool IsServerlessWorkerSubstrate(string? substrate) + => string.Equals(substrate, "Sandbox", StringComparison.OrdinalIgnoreCase) + || string.Equals(substrate, "AcaSessionPool", StringComparison.OrdinalIgnoreCase); + static DurableTaskWorkerWorkItemFilters.ActivityFilter[] MergeActivityFilters( IReadOnlyList existingFilters, IEnumerable activityNames) diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index 0332e00a..ff466f42 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -582,6 +582,25 @@ public async Task EnableServerlessActivities_RegistersDeclarationHostedService() services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IHostedService)); } + [Fact] + public void EnableServerlessActivities_WhenRunningInServerlessWorker_Throws() + { + // Arrange + using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "Sandbox"); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + // Act + Action action = () => mockBuilder.Object.EnableServerlessActivities(); + + // Assert + action.Should().Throw() + .WithMessage("EnableServerlessActivities is for declaring serverless activities from the coordinator app. DTS serverless workers should use UseServerlessWorker instead."); + services.Should().NotContain(descriptor => descriptor.ServiceType == typeof(IHostedService)); + } + [Fact] public void ServerlessActivityAnnotationResolver_UsesWorkerProfileConfigureForDeclarationOptions() { From dcbb904ff814b342673475ba08c1d8e2d27b3d0f Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 27 May 2026 08:32:55 -0700 Subject: [PATCH 35/81] Refactor serverless annotation resolution Introduce a cached annotation catalog and centralize task-name resolution. ServerlessActivityAnnotationResolver now exposes ResolveDeclarations and uses a Lazy-scanned AnnotationCatalog (ProfileMetadata/ActivityMetadata) via ScanAnnotations to collect and validate profiles/activities, preventing duplicate scans and improving clarity. Extract DurableTask name logic into a new ServerlessTaskNameResolver and update ServerlessOptions.AddActivity and activity-name resolution to use it. Update DurableTaskSchedulerServerlessWorkerExtensions and tests to call the new ResolveDeclarations API. --- ...TaskSchedulerServerlessWorkerExtensions.cs | 2 +- .../ServerlessActivityAnnotationResolver.cs | 104 +++++++++--------- .../Worker/Serverless/ServerlessOptions.cs | 10 +- .../Serverless/ServerlessTaskNameResolver.cs | 23 ++++ .../ServerlessActivitiesTests.cs | 8 +- 5 files changed, 81 insertions(+), 66 deletions(-) create mode 100644 src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessTaskNameResolver.cs diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs index b80a0e3d..f61812b1 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -135,7 +135,7 @@ static ServerlessActivityDeclarationHostedService CreateServerlessActivityDeclar return new ServerlessActivityDeclarationHostedService( CreateServerlessActivitiesClient(services, builderName), - ServerlessActivityAnnotationResolver.Resolve(schedulerOptions.TaskHubName), + ServerlessActivityAnnotationResolver.ResolveDeclarations(schedulerOptions.TaskHubName), runtimeOptions, loggerFactory.CreateLogger()); } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs index 68a1b110..4731a5bb 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Reflection; +using System.Threading; namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; @@ -10,58 +11,34 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; /// static class ServerlessActivityAnnotationResolver { + static readonly Lazy Catalog = new(ScanAnnotations, LazyThreadSafetyMode.ExecutionAndPublication); + /// /// Resolves annotated serverless declarations for the specified task hub. /// /// The task hub name. /// The resolved serverless declaration options. - public static IReadOnlyList Resolve(string taskHub) + public static IReadOnlyList ResolveDeclarations(string taskHub) { string normalizedTaskHub = string.IsNullOrWhiteSpace(taskHub) ? throw new InvalidOperationException("Serverless activity declaration requires a task hub name.") : taskHub.Trim(); - Dictionary profiles = new(StringComparer.Ordinal); - Dictionary activityOwners = new(StringComparer.Ordinal); - - foreach (Type type in GetCandidateTypes()) - { - if (type.GetCustomAttribute() is { } profile) - { - if (profiles.ContainsKey(profile.WorkerProfileId)) - { - throw new InvalidOperationException($"Serverless worker profile '{profile.WorkerProfileId}' is declared more than once."); - } - - profiles.Add(profile.WorkerProfileId, CreateOptions(normalizedTaskHub, profile, type)); - } - } - - foreach (Type type in GetCandidateTypes()) + AnnotationCatalog catalog = Catalog.Value; + Dictionary optionsByProfile = new(StringComparer.Ordinal); + foreach (ActivityMetadata activity in catalog.Activities) { - if (type.GetCustomAttribute() is not { } activity) + if (!optionsByProfile.TryGetValue(activity.WorkerProfileId, out ServerlessOptions? options)) { - continue; + ProfileMetadata profile = catalog.Profiles[activity.WorkerProfileId]; + options = CreateOptions(normalizedTaskHub, profile); + optionsByProfile.Add(activity.WorkerProfileId, options); } - if (!profiles.TryGetValue(activity.WorkerProfileId, out ServerlessOptions? options)) - { - throw new InvalidOperationException($"Serverless activity '{type.FullName}' references undeclared worker profile '{activity.WorkerProfileId}'."); - } - - string activityName = GetTaskName(type, activity); - if (activityOwners.TryGetValue(activityName, out string? existingProfile)) - { - throw new InvalidOperationException($"Serverless activity '{activityName}' is assigned to both worker profile '{existingProfile}' and '{activity.WorkerProfileId}'."); - } - - activityOwners.Add(activityName, activity.WorkerProfileId); - options.ActivityNames.Add(activityName); + options.ActivityNames.Add(activity.ActivityName); } - return profiles.Values - .Where(static options => options.ActivityNames.Count > 0) - .ToArray(); + return optionsByProfile.Values.ToArray(); } /// @@ -70,45 +47,57 @@ public static IReadOnlyList Resolve(string taskHub) /// The resolved activity names. public static string[] ResolveActivityNames() { - HashSet profiles = new(StringComparer.Ordinal); + return Catalog.Value.Activities + .Select(static activity => activity.ActivityName) + .ToArray(); + } + + static AnnotationCatalog ScanAnnotations() + { + Dictionary profiles = new(StringComparer.Ordinal); + List activities = []; Dictionary activityOwners = new(StringComparer.Ordinal); + List<(Type Type, ServerlessActivityAttribute Attribute)> activityAnnotations = []; foreach (Type type in GetCandidateTypes()) { if (type.GetCustomAttribute() is { } profile) { - if (!profiles.Add(profile.WorkerProfileId)) + if (profiles.ContainsKey(profile.WorkerProfileId)) { throw new InvalidOperationException($"Serverless worker profile '{profile.WorkerProfileId}' is declared more than once."); } + + profiles.Add(profile.WorkerProfileId, new ProfileMetadata(profile.WorkerProfileId, type)); } - } - foreach (Type type in GetCandidateTypes()) - { - if (type.GetCustomAttribute() is not { } activity) + if (type.GetCustomAttribute() is { } activity) { - continue; + activityAnnotations.Add((type, activity)); } + } - if (!profiles.Contains(activity.WorkerProfileId)) + foreach ((Type type, ServerlessActivityAttribute activity) in activityAnnotations) + { + if (!profiles.ContainsKey(activity.WorkerProfileId)) { throw new InvalidOperationException($"Serverless activity '{type.FullName}' references undeclared worker profile '{activity.WorkerProfileId}'."); } - string activityName = GetTaskName(type, activity); + string activityName = GetActivityName(type, activity); if (activityOwners.TryGetValue(activityName, out string? existingProfile)) { throw new InvalidOperationException($"Serverless activity '{activityName}' is assigned to both worker profile '{existingProfile}' and '{activity.WorkerProfileId}'."); } activityOwners.Add(activityName, activity.WorkerProfileId); + activities.Add(new ActivityMetadata(activityName, activity.WorkerProfileId, type)); } - return activityOwners.Keys.ToArray(); + return new AnnotationCatalog(profiles, activities); } - static ServerlessOptions CreateOptions(string taskHub, ServerlessWorkerProfileAttribute profile, Type profileType) + static ServerlessOptions CreateOptions(string taskHub, ProfileMetadata profile) { ServerlessOptions options = new() { @@ -116,7 +105,7 @@ static ServerlessOptions CreateOptions(string taskHub, ServerlessWorkerProfileAt WorkerProfileId = profile.WorkerProfileId, }; - ConfigureProfile(profileType, options); + ConfigureProfile(profile.Type, options); return options; } @@ -159,7 +148,7 @@ static IEnumerable GetCandidateTypes() } } - static string GetTaskName(Type type, ServerlessActivityAttribute activity) + static string GetActivityName(Type type, ServerlessActivityAttribute activity) { Check.NotNull(type); if (!string.IsNullOrWhiteSpace(activity.Name)) @@ -172,8 +161,19 @@ static string GetTaskName(Type type, ServerlessActivityAttribute activity) throw new InvalidOperationException($"Serverless activity declaration marker '{type.FullName}' must specify {nameof(ServerlessActivityAttribute.Name)} or implement {nameof(ITaskActivity)}."); } - return Attribute.GetCustomAttribute(type, typeof(DurableTaskAttribute)) is DurableTaskAttribute { Name.Name: not null and not "" } attr - ? attr.Name.Name - : type.Name; + return ServerlessTaskNameResolver.GetTaskName(type); } + + sealed record AnnotationCatalog( + IReadOnlyDictionary Profiles, + IReadOnlyList Activities); + + sealed record ProfileMetadata( + string WorkerProfileId, + Type Type); + + sealed record ActivityMetadata( + string ActivityName, + string WorkerProfileId, + Type Type); } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs index e3d07e11..b9ef855e 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs @@ -110,14 +110,6 @@ public ServerlessOptions AddActivity(string activityName) public ServerlessOptions AddActivity() where TActivity : class, ITaskActivity { - return this.AddActivity(GetTaskName(typeof(TActivity))); - } - - static string GetTaskName(Type type) - { - Check.NotNull(type); - return Attribute.GetCustomAttribute(type, typeof(DurableTaskAttribute)) is DurableTaskAttribute { Name.Name: not null and not "" } attr - ? attr.Name.Name - : type.Name; + return this.AddActivity(ServerlessTaskNameResolver.GetTaskName(typeof(TActivity))); } } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessTaskNameResolver.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessTaskNameResolver.cs new file mode 100644 index 00000000..58ecb7da --- /dev/null +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessTaskNameResolver.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; + +/// +/// Resolves Durable Task names for serverless activity declaration APIs. +/// +static class ServerlessTaskNameResolver +{ + /// + /// Gets the Durable Task name for the specified task type. + /// + /// The task type. + /// The resolved task name. + public static string GetTaskName(Type type) + { + Check.NotNull(type); + return Attribute.GetCustomAttribute(type, typeof(DurableTaskAttribute)) is DurableTaskAttribute { Name.Name: not null and not "" } attr + ? attr.Name.Name + : type.Name; + } +} diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index ff466f42..4f812ac4 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -602,7 +602,7 @@ public void EnableServerlessActivities_WhenRunningInServerlessWorker_Throws() } [Fact] - public void ServerlessActivityAnnotationResolver_UsesWorkerProfileConfigureForDeclarationOptions() + public void ServerlessActivityAnnotationResolver_ResolveDeclarations_UsesWorkerProfileConfigure() { // Arrange using EnvironmentVariableScope image = new("DTS_SERVERLESS_ACTIVITY_IMAGE", "example.com/not-used:latest"); @@ -611,7 +611,7 @@ public void ServerlessActivityAnnotationResolver_UsesWorkerProfileConfigureForDe using EnvironmentVariableScope maxActivities = new("DTS_SERVERLESS_MAX_ACTIVITIES", "99"); // Act - ServerlessOptions options = ServerlessActivityAnnotationResolver.Resolve(TaskHub) + ServerlessOptions options = ServerlessActivityAnnotationResolver.ResolveDeclarations(TaskHub) .Single(options => options.WorkerProfileId == "annotated-profile"); ServerlessActivityDeclaration declaration = ServerlessActivityConfiguration.BuildDeclaration( options, @@ -630,10 +630,10 @@ public void ServerlessActivityAnnotationResolver_UsesWorkerProfileConfigureForDe } [Fact] - public void ServerlessActivityAnnotationResolver_AllowsDeclarationMarkerClassWithExplicitName() + public void ServerlessActivityAnnotationResolver_ResolveDeclarations_AllowsMarkerClassWithExplicitName() { // Act - ServerlessOptions options = ServerlessActivityAnnotationResolver.Resolve(TaskHub) + ServerlessOptions options = ServerlessActivityAnnotationResolver.ResolveDeclarations(TaskHub) .Single(options => options.WorkerProfileId == "marker-profile"); // Assert From 862f5f2069595ee730deeaa2a1e01864b62008d1 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 27 May 2026 08:57:42 -0700 Subject: [PATCH 36/81] Resolve and validate declared serverless activities Make serverless activity resolution task-hub aware and validate ownership. ServerlessActivityAnnotationResolver now resolves declarations per task hub, builds profile options, validates that an activity name isn't assigned to multiple profiles, and exposes ResolveDeclaredActivityNames. DurableTaskSchedulerServerlessWorkerExtensions uses the task-hub-specific declarations to exclude remote activities from local execution. ServerlessActivityAttribute docs updated to clarify Name is optional and resolution rules. Samples updated to register declared activities via IServerlessWorkerProfile.Configure (and remove marker declaration), and tests updated to reflect the new resolution and validation behavior. --- samples/serverless/README.md | 4 +- samples/serverless/main-app/Activities.cs | 4 - samples/serverless/main-app/WorkerProfiles.cs | 1 + ...TaskSchedulerServerlessWorkerExtensions.cs | 9 ++- .../ServerlessActivityAnnotationResolver.cs | 75 ++++++++++++------- .../Serverless/ServerlessActivityAttribute.cs | 3 +- .../ServerlessActivitiesTests.cs | 39 ++++------ 7 files changed, 74 insertions(+), 61 deletions(-) diff --git a/samples/serverless/README.md b/samples/serverless/README.md index bd37776f..0df455e0 100644 --- a/samples/serverless/README.md +++ b/samples/serverless/README.md @@ -31,8 +31,8 @@ docker push $image The main app uses `DefaultAzureCredential`; sign in with Azure CLI or configure another supported Azure identity before running it. After pushing the remote worker image, set `ContainerImage` in `main-app/WorkerProfiles.cs` to the pushed image reference. The same profile -class declares CPU, memory, max concurrency, and a customer environment variable -that the remote hello activity echoes. +class declares the remote activity name, CPU, memory, max concurrency, and a +customer environment variable that the remote hello activity echoes. ```powershell $env:DTS_ENDPOINT = "https://" diff --git a/samples/serverless/main-app/Activities.cs b/samples/serverless/main-app/Activities.cs index ffbf5b30..79dfaee2 100644 --- a/samples/serverless/main-app/Activities.cs +++ b/samples/serverless/main-app/Activities.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.DurableTask.Worker.AzureManaged.Serverless; - namespace Microsoft.DurableTask.Samples.Serverless.MainApp; internal static class ServerlessTaskNames @@ -19,5 +17,3 @@ public override Task RunAsync(TaskActivityContext context, string input) => Task.FromResult($"hello locally: {input}"); } -[ServerlessActivity("default", Name = ServerlessTaskNames.RemoteHello)] -internal sealed class RemoteHelloDeclaration; diff --git a/samples/serverless/main-app/WorkerProfiles.cs b/samples/serverless/main-app/WorkerProfiles.cs index 9899df9c..4ea8b8e2 100644 --- a/samples/serverless/main-app/WorkerProfiles.cs +++ b/samples/serverless/main-app/WorkerProfiles.cs @@ -15,5 +15,6 @@ public void Configure(ServerlessOptions options) options.Memory = "2048Mi"; options.MaxConcurrentActivities = 1; options.EnvironmentVariables["SERVERLESS_SAMPLE_MARKER"] = "serverless-dotnet-sample-marker"; + options.AddActivity(ServerlessTaskNames.RemoteHello); } } \ No newline at end of file diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs index f61812b1..d8ef2914 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -36,7 +36,8 @@ public static IDurableTaskWorkerBuilder EnableServerlessActivities(this IDurable ThrowIfServerlessWorkerRuntime(); builder.Services.AddOptions(builder.Name) - .PostConfigure(ExcludeAnnotatedServerlessActivitiesFromLocalExecution); + .PostConfigure>((filters, schedulerOptions) => + ExcludeDeclaredServerlessActivitiesFromLocalExecution(filters, schedulerOptions.Get(builder.Name).TaskHubName)); builder.Services.AddSingleton(sp => CreateServerlessActivityDeclarationHostedService(sp, builder.Name)); return builder; @@ -99,9 +100,11 @@ public static IDurableTaskWorkerBuilder UseServerlessWorker(this IDurableTaskWor return builder; } - static void ExcludeAnnotatedServerlessActivitiesFromLocalExecution(DurableTaskWorkerWorkItemFilters filters) + static void ExcludeDeclaredServerlessActivitiesFromLocalExecution( + DurableTaskWorkerWorkItemFilters filters, + string taskHub) { - string[] activityNames = ServerlessActivityAnnotationResolver.ResolveActivityNames(); + string[] activityNames = ServerlessActivityAnnotationResolver.ResolveDeclaredActivityNames(taskHub); if (activityNames.Length == 0) { return; diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs index 4731a5bb..5029bf04 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs @@ -24,31 +24,29 @@ public static IReadOnlyList ResolveDeclarations(string taskHu ? throw new InvalidOperationException("Serverless activity declaration requires a task hub name.") : taskHub.Trim(); - AnnotationCatalog catalog = Catalog.Value; - Dictionary optionsByProfile = new(StringComparer.Ordinal); - foreach (ActivityMetadata activity in catalog.Activities) + Dictionary optionsByProfile = CreateOptionsByProfile(normalizedTaskHub); + foreach (ActivityMetadata activity in Catalog.Value.Activities) { - if (!optionsByProfile.TryGetValue(activity.WorkerProfileId, out ServerlessOptions? options)) - { - ProfileMetadata profile = catalog.Profiles[activity.WorkerProfileId]; - options = CreateOptions(normalizedTaskHub, profile); - optionsByProfile.Add(activity.WorkerProfileId, options); - } - - options.ActivityNames.Add(activity.ActivityName); + optionsByProfile[activity.WorkerProfileId].ActivityNames.Add(activity.ActivityName); } - return optionsByProfile.Values.ToArray(); + ValidateActivityOwnership(optionsByProfile.Values); + + return optionsByProfile.Values + .Where(static options => ServerlessActivityConfiguration.ResolveActivityNames(options.ActivityNames).Length > 0) + .ToArray(); } /// - /// Resolves annotated serverless activity names. + /// Resolves activity names declared by serverless worker profiles and activity annotations. /// + /// The task hub name. /// The resolved activity names. - public static string[] ResolveActivityNames() + public static string[] ResolveDeclaredActivityNames(string taskHub) { - return Catalog.Value.Activities - .Select(static activity => activity.ActivityName) + return ResolveDeclarations(taskHub) + .SelectMany(static options => ServerlessActivityConfiguration.ResolveActivityNames(options.ActivityNames)) + .Distinct(StringComparer.Ordinal) .ToArray(); } @@ -56,7 +54,6 @@ static AnnotationCatalog ScanAnnotations() { Dictionary profiles = new(StringComparer.Ordinal); List activities = []; - Dictionary activityOwners = new(StringComparer.Ordinal); List<(Type Type, ServerlessActivityAttribute Attribute)> activityAnnotations = []; foreach (Type type in GetCandidateTypes()) @@ -85,18 +82,24 @@ static AnnotationCatalog ScanAnnotations() } string activityName = GetActivityName(type, activity); - if (activityOwners.TryGetValue(activityName, out string? existingProfile)) - { - throw new InvalidOperationException($"Serverless activity '{activityName}' is assigned to both worker profile '{existingProfile}' and '{activity.WorkerProfileId}'."); - } - - activityOwners.Add(activityName, activity.WorkerProfileId); activities.Add(new ActivityMetadata(activityName, activity.WorkerProfileId, type)); } return new AnnotationCatalog(profiles, activities); } + static Dictionary CreateOptionsByProfile(string taskHub) + { + AnnotationCatalog catalog = Catalog.Value; + Dictionary optionsByProfile = new(StringComparer.Ordinal); + foreach (ProfileMetadata profile in catalog.Profiles.Values) + { + optionsByProfile.Add(profile.WorkerProfileId, CreateOptions(taskHub, profile)); + } + + return optionsByProfile; + } + static ServerlessOptions CreateOptions(string taskHub, ProfileMetadata profile) { ServerlessOptions options = new() @@ -148,17 +151,35 @@ static IEnumerable GetCandidateTypes() } } + static void ValidateActivityOwnership(IEnumerable declarations) + { + Dictionary activityOwners = new(StringComparer.Ordinal); + foreach (ServerlessOptions declaration in declarations) + { + foreach (string activityName in ServerlessActivityConfiguration.ResolveActivityNames(declaration.ActivityNames)) + { + if (activityOwners.TryGetValue(activityName, out string? existingProfile) + && !string.Equals(existingProfile, declaration.WorkerProfileId, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Serverless activity '{activityName}' is assigned to both worker profile '{existingProfile}' and '{declaration.WorkerProfileId}'."); + } + + activityOwners[activityName] = declaration.WorkerProfileId; + } + } + } + static string GetActivityName(Type type, ServerlessActivityAttribute activity) { Check.NotNull(type); - if (!string.IsNullOrWhiteSpace(activity.Name)) + if (!typeof(ITaskActivity).IsAssignableFrom(type)) { - return activity.Name.Trim(); + throw new InvalidOperationException($"Serverless activity '{type.FullName}' must implement {nameof(ITaskActivity)}. Remote-only activities should be declared in {nameof(IServerlessWorkerProfile)}.{nameof(IServerlessWorkerProfile.Configure)}."); } - if (!typeof(ITaskActivity).IsAssignableFrom(type)) + if (!string.IsNullOrWhiteSpace(activity.Name)) { - throw new InvalidOperationException($"Serverless activity declaration marker '{type.FullName}' must specify {nameof(ServerlessActivityAttribute.Name)} or implement {nameof(ITaskActivity)}."); + return activity.Name.Trim(); } return ServerlessTaskNameResolver.GetTaskName(type); diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAttribute.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAttribute.cs index 386aaed9..c22a2c30 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAttribute.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAttribute.cs @@ -26,7 +26,8 @@ public ServerlessActivityAttribute(string workerProfileId) public string WorkerProfileId { get; } /// - /// Gets or sets the activity name when this attribute is applied to a declaration marker class. + /// Gets or sets the serverless activity name. When omitted, the name is resolved from + /// or the activity type name. /// public string? Name { get; set; } } diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index 4f812ac4..67a0a75c 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -549,6 +549,9 @@ public async Task EnableServerlessActivities_ConfiguresLocalWorkerExclusionFilte using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); ServiceCollection services = new(); + services.Configure( + Options.DefaultName, + options => options.TaskHubName = TaskHub); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); @@ -560,7 +563,7 @@ public async Task EnableServerlessActivities_ConfiguresLocalWorkerExclusionFilte DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); // Assert - filters.ExcludedActivities.Select(filter => filter.Name).Should().Contain("AnnotatedRemoteHello"); + filters.ExcludedActivities.Select(filter => filter.Name).Should().Contain(["ConfiguredRemoteHello", "AnnotatedRemoteHello"]); filters.Activities.Should().BeEmpty(); } @@ -619,7 +622,7 @@ public void ServerlessActivityAnnotationResolver_ResolveDeclarations_UsesWorkerP // Assert declaration.WorkerProfileId.Should().Be("annotated-profile"); - declaration.ActivityNames.Should().Equal("AnnotatedRemoteHello"); + declaration.ActivityNames.Should().Equal("ConfiguredRemoteHello", "AnnotatedRemoteHello", "ExplicitServerlessName"); declaration.Image.ImageRef.Should().Be("example.com/repo/annotated-worker:latest"); declaration.Resources.Cpu.Should().Be("500m"); declaration.Resources.Memory.Should().Be("1024Mi"); @@ -630,28 +633,17 @@ public void ServerlessActivityAnnotationResolver_ResolveDeclarations_UsesWorkerP } [Fact] - public void ServerlessActivityAnnotationResolver_ResolveDeclarations_AllowsMarkerClassWithExplicitName() - { - // Act - ServerlessOptions options = ServerlessActivityAnnotationResolver.ResolveDeclarations(TaskHub) - .Single(options => options.WorkerProfileId == "marker-profile"); - - // Assert - options.ActivityNames.Should().Equal("MarkerRemoteHello"); - } - - [Fact] - public void ServerlessActivityAnnotationResolver_ResolveActivityNames_DoesNotRequireTaskHubOrConfigureProfiles() + public void ServerlessActivityAnnotationResolver_ResolveDeclaredActivityNames_UsesProfileConfigure() { // Arrange int before = AnnotatedWorkerProfile.ConfigureCallCount; // Act - string[] activityNames = ServerlessActivityAnnotationResolver.ResolveActivityNames(); + string[] activityNames = ServerlessActivityAnnotationResolver.ResolveDeclaredActivityNames(TaskHub); // Assert - activityNames.Should().Contain(["AnnotatedRemoteHello", "MarkerRemoteHello"]); - AnnotatedWorkerProfile.ConfigureCallCount.Should().Be(before); + activityNames.Should().Contain(["ConfiguredRemoteHello", "AnnotatedRemoteHello", "ExplicitServerlessName"]); + AnnotatedWorkerProfile.ConfigureCallCount.Should().BeGreaterThan(before); } [Fact] @@ -785,6 +777,7 @@ public void Configure(ServerlessOptions options) options.Memory = "1024Mi"; options.MaxConcurrentActivities = 4; options.EnvironmentVariables["CUSTOM_ENV"] = "configured-value"; + options.AddActivity("ConfiguredRemoteHello"); } } @@ -798,18 +791,16 @@ public override Task RunAsync(TaskActivityContext context, string input) } } - [ServerlessWorkerProfile("marker-profile")] - sealed class MarkerWorkerProfile : IServerlessWorkerProfile + [DurableTask("DurableTaskNameIgnoredByServerlessAttribute")] + [ServerlessActivity("annotated-profile", Name = "ExplicitServerlessName")] + sealed class ExplicitNameServerlessActivity : TaskActivity { - public void Configure(ServerlessOptions options) + public override Task RunAsync(TaskActivityContext context, string input) { - options.ContainerImage = "example.com/repo/marker-worker:latest"; + return Task.FromResult(input); } } - [ServerlessActivity("marker-profile", Name = "MarkerRemoteHello")] - sealed class MarkerRemoteHelloDeclaration; - sealed class FakeServerlessActivitiesClient : IServerlessActivitiesClient { readonly Queue queuedSessions = new(); From d907e3199a11844f6c960770a9c6e93382db2817 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 27 May 2026 09:16:37 -0700 Subject: [PATCH 37/81] Serverless: use appsettings and remove marker Add appsettings.json to store scheduler endpoint and task hub (ServerlessSample:EndpointAddress/TaskHubName) and update README with instructions to run the sample. Program.cs now reads configuration from builder.Configuration, uses a constant input, and removes the GetRequiredEnvironmentVariable helper. WorkerProfiles no longer sets the SERVERLESS_SAMPLE_MARKER env var, and the remote activity stops reading/echoing the marker. Expected output in the README was updated to match the new activity output. --- samples/serverless/README.md | 26 +++++++++++++------ samples/serverless/main-app/Program.cs | 18 +++++-------- samples/serverless/main-app/WorkerProfiles.cs | 3 +-- samples/serverless/main-app/appsettings.json | 6 +++++ .../serverless/remote-worker/Activities.cs | 3 +-- 5 files changed, 32 insertions(+), 24 deletions(-) create mode 100644 samples/serverless/main-app/appsettings.json diff --git a/samples/serverless/README.md b/samples/serverless/README.md index 0df455e0..1fc4caab 100644 --- a/samples/serverless/README.md +++ b/samples/serverless/README.md @@ -31,22 +31,32 @@ docker push $image The main app uses `DefaultAzureCredential`; sign in with Azure CLI or configure another supported Azure identity before running it. After pushing the remote worker image, set `ContainerImage` in `main-app/WorkerProfiles.cs` to the pushed image reference. The same profile -class declares the remote activity name, CPU, memory, max concurrency, and a -customer environment variable that the remote hello activity echoes. +class declares the remote activity name, CPU, memory, and max concurrency. -```powershell -$env:DTS_ENDPOINT = "https://" -$env:DTS_TASK_HUB = "" -$env:DTS_SAMPLE_HELLO_INPUT = "serverless-sample" +Update `main-app/appsettings.json` with your scheduler endpoint and task hub: + +```json +{ + "ServerlessSample": { + "EndpointAddress": "https://", + "TaskHubName": "ServerlessPocHub" + } +} +``` -dotnet run --project .\samples\serverless\main-app\main-app.csproj +Then run the main app: + +```powershell +Push-Location .\samples\serverless\main-app +dotnet run +Pop-Location ``` Expected output includes the serverless activity result: ```text Runtime status: Completed -Output: "hello locally: serverless-sample; hello remotely from pid=: serverless-sample; marker=serverless-dotnet-sample-marker" +Output: "hello locally: serverless-sample; hello remotely from pid=: serverless-sample" ``` Use the Durable Task Scheduler dashboard's Serverless Activities preview tab to inspect serverless activity runtimes and stream runtime logs. diff --git a/samples/serverless/main-app/Program.cs b/samples/serverless/main-app/Program.cs index 084828c8..1e6f4683 100644 --- a/samples/serverless/main-app/Program.cs +++ b/samples/serverless/main-app/Program.cs @@ -9,18 +9,17 @@ using Microsoft.DurableTask.Samples.Serverless.MainApp; using Microsoft.DurableTask.Worker; using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -string endpoint = GetRequiredEnvironmentVariable("DTS_ENDPOINT"); -string taskHub = Environment.GetEnvironmentVariable("DTS_TASK_HUB") ?? "ServerlessPocHub"; -string input = args.Length > 0 - ? args[0] - : Environment.GetEnvironmentVariable("DTS_SAMPLE_HELLO_INPUT") ?? "serverless-sample"; -TokenCredential credential = new DefaultAzureCredential(); +const string Input = "serverless-sample"; HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +string endpoint = builder.Configuration["ServerlessSample:EndpointAddress"]!; +string taskHub = builder.Configuration["ServerlessSample:TaskHubName"]!; +TokenCredential credential = new DefaultAzureCredential(); builder.Logging.AddSimpleConsole(options => { options.SingleLine = true; @@ -58,7 +57,7 @@ DurableTaskClient client = host.Services.GetRequiredService(); string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( ServerlessTaskNames.HelloOrchestrator, - input: input); + input: Input); OrchestrationMetadata? result = await client.WaitForInstanceCompletionAsync( instanceId, getInputsAndOutputs: true); @@ -68,8 +67,3 @@ Console.WriteLine($"Output: {result?.SerializedOutput ?? ""}"); await host.StopAsync(); - -static string GetRequiredEnvironmentVariable(string name) - => Environment.GetEnvironmentVariable(name) - ?? throw new InvalidOperationException($"An environment variable named '{name}' is required."); - diff --git a/samples/serverless/main-app/WorkerProfiles.cs b/samples/serverless/main-app/WorkerProfiles.cs index 4ea8b8e2..73543bc8 100644 --- a/samples/serverless/main-app/WorkerProfiles.cs +++ b/samples/serverless/main-app/WorkerProfiles.cs @@ -14,7 +14,6 @@ public void Configure(ServerlessOptions options) options.Cpu = "1000m"; options.Memory = "2048Mi"; options.MaxConcurrentActivities = 1; - options.EnvironmentVariables["SERVERLESS_SAMPLE_MARKER"] = "serverless-dotnet-sample-marker"; options.AddActivity(ServerlessTaskNames.RemoteHello); } -} \ No newline at end of file +} diff --git a/samples/serverless/main-app/appsettings.json b/samples/serverless/main-app/appsettings.json new file mode 100644 index 00000000..92ad688a --- /dev/null +++ b/samples/serverless/main-app/appsettings.json @@ -0,0 +1,6 @@ +{ + "ServerlessSample": { + "EndpointAddress": "https://", + "TaskHubName": "ServerlessPocHub" + } +} diff --git a/samples/serverless/remote-worker/Activities.cs b/samples/serverless/remote-worker/Activities.cs index e5c58470..b5ae5ebc 100644 --- a/samples/serverless/remote-worker/Activities.cs +++ b/samples/serverless/remote-worker/Activities.cs @@ -10,7 +10,6 @@ internal sealed class RemoteHelloActivity : TaskActivity { public override Task RunAsync(TaskActivityContext context, string input) { - string marker = Environment.GetEnvironmentVariable("SERVERLESS_SAMPLE_MARKER") ?? ""; - return Task.FromResult($"hello remotely from {Environment.MachineName} pid={Environment.ProcessId}: {input}; marker={marker}"); + return Task.FromResult($"hello remotely from {Environment.MachineName} pid={Environment.ProcessId}: {input}"); } } From f05c6b39db1ceb9b1b2754faeb485366c16d775b Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 27 May 2026 10:15:02 -0700 Subject: [PATCH 38/81] Inline task name resolver; remove helper class Inline the logic previously in ServerlessTaskNameResolver into ServerlessActivityAnnotationResolver and ServerlessOptions, and delete the now-unused ServerlessTaskNameResolver.cs. Both locations now resolve task names by checking for a DurableTaskAttribute (using DurableTaskAttribute.Name.Name when present and non-empty) and falling back to the type's Name; ServerlessOptions also adds a GetTaskName helper and a null check. This reduces indirection and removes the obsolete helper class. --- .../ServerlessActivityAnnotationResolver.cs | 4 +++- .../Worker/Serverless/ServerlessOptions.cs | 10 +++++++- .../Serverless/ServerlessTaskNameResolver.cs | 23 ------------------- 3 files changed, 12 insertions(+), 25 deletions(-) delete mode 100644 src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessTaskNameResolver.cs diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs index 5029bf04..ec7f2532 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs @@ -182,7 +182,9 @@ static string GetActivityName(Type type, ServerlessActivityAttribute activity) return activity.Name.Trim(); } - return ServerlessTaskNameResolver.GetTaskName(type); + return Attribute.GetCustomAttribute(type, typeof(DurableTaskAttribute)) is DurableTaskAttribute { Name.Name: not null and not "" } attr + ? attr.Name.Name + : type.Name; } sealed record AnnotationCatalog( diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs index b9ef855e..e3d07e11 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs @@ -110,6 +110,14 @@ public ServerlessOptions AddActivity(string activityName) public ServerlessOptions AddActivity() where TActivity : class, ITaskActivity { - return this.AddActivity(ServerlessTaskNameResolver.GetTaskName(typeof(TActivity))); + return this.AddActivity(GetTaskName(typeof(TActivity))); + } + + static string GetTaskName(Type type) + { + Check.NotNull(type); + return Attribute.GetCustomAttribute(type, typeof(DurableTaskAttribute)) is DurableTaskAttribute { Name.Name: not null and not "" } attr + ? attr.Name.Name + : type.Name; } } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessTaskNameResolver.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessTaskNameResolver.cs deleted file mode 100644 index 58ecb7da..00000000 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessTaskNameResolver.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; - -/// -/// Resolves Durable Task names for serverless activity declaration APIs. -/// -static class ServerlessTaskNameResolver -{ - /// - /// Gets the Durable Task name for the specified task type. - /// - /// The task type. - /// The resolved task name. - public static string GetTaskName(Type type) - { - Check.NotNull(type); - return Attribute.GetCustomAttribute(type, typeof(DurableTaskAttribute)) is DurableTaskAttribute { Name.Name: not null and not "" } attr - ? attr.Name.Name - : type.Name; - } -} From cf26c583e1752cb25372d77c92fbedc8fc81a050 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 27 May 2026 12:07:27 -0700 Subject: [PATCH 39/81] Rename annotation resolver to declaration Rename ServerlessActivityAnnotationResolver to ServerlessActivityDeclarationResolver and update references accordingly. Remove the ServerlessActivityAttribute type and adjust the resolver to scan worker profiles (Configure) for declarations instead of activity annotations. Update DurableTaskSchedulerServerlessWorkerExtensions to call the new resolver and modify tests to expect only profile-configured activities. This simplifies the serverless activity declaration model to rely on worker profile configuration. --- ...TaskSchedulerServerlessWorkerExtensions.cs | 4 +- .../Serverless/ServerlessActivityAttribute.cs | 33 ------- ... ServerlessActivityDeclarationResolver.cs} | 94 ++++--------------- .../ServerlessActivitiesTests.cs | 36 ++----- 4 files changed, 27 insertions(+), 140 deletions(-) delete mode 100644 src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAttribute.cs rename src/Extensions/AzureManagedServerless/Worker/Serverless/{ServerlessActivityAnnotationResolver.cs => ServerlessActivityDeclarationResolver.cs} (54%) diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs index d8ef2914..ef17b71d 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -104,7 +104,7 @@ static void ExcludeDeclaredServerlessActivitiesFromLocalExecution( DurableTaskWorkerWorkItemFilters filters, string taskHub) { - string[] activityNames = ServerlessActivityAnnotationResolver.ResolveDeclaredActivityNames(taskHub); + string[] activityNames = ServerlessActivityDeclarationResolver.ResolveDeclaredActivityNames(taskHub); if (activityNames.Length == 0) { return; @@ -138,7 +138,7 @@ static ServerlessActivityDeclarationHostedService CreateServerlessActivityDeclar return new ServerlessActivityDeclarationHostedService( CreateServerlessActivitiesClient(services, builderName), - ServerlessActivityAnnotationResolver.ResolveDeclarations(schedulerOptions.TaskHubName), + ServerlessActivityDeclarationResolver.ResolveDeclarations(schedulerOptions.TaskHubName), runtimeOptions, loggerFactory.CreateLogger()); } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAttribute.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAttribute.cs deleted file mode 100644 index c22a2c30..00000000 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAttribute.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; - -/// -/// Marks an activity as serverless and associates it with a serverless worker profile. -/// -[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] -public sealed class ServerlessActivityAttribute : Attribute -{ - /// - /// Initializes a new instance of the class. - /// - /// The worker profile ID that owns this activity. - public ServerlessActivityAttribute(string workerProfileId) - { - this.WorkerProfileId = string.IsNullOrWhiteSpace(workerProfileId) - ? throw new ArgumentException("Serverless activity worker profile ID cannot be empty.", nameof(workerProfileId)) - : workerProfileId.Trim(); - } - - /// - /// Gets the worker profile ID that owns this activity. - /// - public string WorkerProfileId { get; } - - /// - /// Gets or sets the serverless activity name. When omitted, the name is resolved from - /// or the activity type name. - /// - public string? Name { get; set; } -} diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationResolver.cs similarity index 54% rename from src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs rename to src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationResolver.cs index ec7f2532..268b1d0c 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityAnnotationResolver.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationResolver.cs @@ -7,14 +7,14 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; /// -/// Resolves serverless worker profile and activity annotations from loaded assemblies. +/// Resolves serverless activity declarations from worker profile annotations. /// -static class ServerlessActivityAnnotationResolver +static class ServerlessActivityDeclarationResolver { - static readonly Lazy Catalog = new(ScanAnnotations, LazyThreadSafetyMode.ExecutionAndPublication); + static readonly Lazy Profiles = new(ScanProfiles, LazyThreadSafetyMode.ExecutionAndPublication); /// - /// Resolves annotated serverless declarations for the specified task hub. + /// Resolves serverless declarations for the specified task hub. /// /// The task hub name. /// The resolved serverless declaration options. @@ -24,21 +24,17 @@ public static IReadOnlyList ResolveDeclarations(string taskHu ? throw new InvalidOperationException("Serverless activity declaration requires a task hub name.") : taskHub.Trim(); - Dictionary optionsByProfile = CreateOptionsByProfile(normalizedTaskHub); - foreach (ActivityMetadata activity in Catalog.Value.Activities) - { - optionsByProfile[activity.WorkerProfileId].ActivityNames.Add(activity.ActivityName); - } - - ValidateActivityOwnership(optionsByProfile.Values); - - return optionsByProfile.Values + ServerlessOptions[] declarations = Profiles.Value + .Select(profile => CreateOptions(normalizedTaskHub, profile)) .Where(static options => ServerlessActivityConfiguration.ResolveActivityNames(options.ActivityNames).Length > 0) .ToArray(); + + ValidateActivityOwnership(declarations); + return declarations; } /// - /// Resolves activity names declared by serverless worker profiles and activity annotations. + /// Resolves activity names declared by serverless worker profiles. /// /// The task hub name. /// The resolved activity names. @@ -50,54 +46,25 @@ public static string[] ResolveDeclaredActivityNames(string taskHub) .ToArray(); } - static AnnotationCatalog ScanAnnotations() + static ProfileMetadata[] ScanProfiles() { Dictionary profiles = new(StringComparer.Ordinal); - List activities = []; - List<(Type Type, ServerlessActivityAttribute Attribute)> activityAnnotations = []; - foreach (Type type in GetCandidateTypes()) { - if (type.GetCustomAttribute() is { } profile) + if (type.GetCustomAttribute() is not { } profile) { - if (profiles.ContainsKey(profile.WorkerProfileId)) - { - throw new InvalidOperationException($"Serverless worker profile '{profile.WorkerProfileId}' is declared more than once."); - } - - profiles.Add(profile.WorkerProfileId, new ProfileMetadata(profile.WorkerProfileId, type)); - } - - if (type.GetCustomAttribute() is { } activity) - { - activityAnnotations.Add((type, activity)); + continue; } - } - foreach ((Type type, ServerlessActivityAttribute activity) in activityAnnotations) - { - if (!profiles.ContainsKey(activity.WorkerProfileId)) + if (profiles.ContainsKey(profile.WorkerProfileId)) { - throw new InvalidOperationException($"Serverless activity '{type.FullName}' references undeclared worker profile '{activity.WorkerProfileId}'."); + throw new InvalidOperationException($"Serverless worker profile '{profile.WorkerProfileId}' is declared more than once."); } - string activityName = GetActivityName(type, activity); - activities.Add(new ActivityMetadata(activityName, activity.WorkerProfileId, type)); - } - - return new AnnotationCatalog(profiles, activities); - } - - static Dictionary CreateOptionsByProfile(string taskHub) - { - AnnotationCatalog catalog = Catalog.Value; - Dictionary optionsByProfile = new(StringComparer.Ordinal); - foreach (ProfileMetadata profile in catalog.Profiles.Values) - { - optionsByProfile.Add(profile.WorkerProfileId, CreateOptions(taskHub, profile)); + profiles.Add(profile.WorkerProfileId, new ProfileMetadata(profile.WorkerProfileId, type)); } - return optionsByProfile; + return profiles.Values.ToArray(); } static ServerlessOptions CreateOptions(string taskHub, ProfileMetadata profile) @@ -169,34 +136,7 @@ static void ValidateActivityOwnership(IEnumerable declaration } } - static string GetActivityName(Type type, ServerlessActivityAttribute activity) - { - Check.NotNull(type); - if (!typeof(ITaskActivity).IsAssignableFrom(type)) - { - throw new InvalidOperationException($"Serverless activity '{type.FullName}' must implement {nameof(ITaskActivity)}. Remote-only activities should be declared in {nameof(IServerlessWorkerProfile)}.{nameof(IServerlessWorkerProfile.Configure)}."); - } - - if (!string.IsNullOrWhiteSpace(activity.Name)) - { - return activity.Name.Trim(); - } - - return Attribute.GetCustomAttribute(type, typeof(DurableTaskAttribute)) is DurableTaskAttribute { Name.Name: not null and not "" } attr - ? attr.Name.Name - : type.Name; - } - - sealed record AnnotationCatalog( - IReadOnlyDictionary Profiles, - IReadOnlyList Activities); - sealed record ProfileMetadata( string WorkerProfileId, Type Type); - - sealed record ActivityMetadata( - string ActivityName, - string WorkerProfileId, - Type Type); } diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index 67a0a75c..dafaaad2 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -543,7 +543,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_StopAsync_Do } [Fact] - public async Task EnableServerlessActivities_ConfiguresLocalWorkerExclusionFilterFromAnnotations() + public async Task EnableServerlessActivities_ConfiguresLocalWorkerExclusionFilterFromWorkerProfiles() { // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); @@ -563,7 +563,7 @@ public async Task EnableServerlessActivities_ConfiguresLocalWorkerExclusionFilte DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); // Assert - filters.ExcludedActivities.Select(filter => filter.Name).Should().Contain(["ConfiguredRemoteHello", "AnnotatedRemoteHello"]); + filters.ExcludedActivities.Select(filter => filter.Name).Should().Contain("ConfiguredRemoteHello"); filters.Activities.Should().BeEmpty(); } @@ -605,7 +605,7 @@ public void EnableServerlessActivities_WhenRunningInServerlessWorker_Throws() } [Fact] - public void ServerlessActivityAnnotationResolver_ResolveDeclarations_UsesWorkerProfileConfigure() + public void ServerlessActivityDeclarationResolver_ResolveDeclarations_UsesWorkerProfileConfigure() { // Arrange using EnvironmentVariableScope image = new("DTS_SERVERLESS_ACTIVITY_IMAGE", "example.com/not-used:latest"); @@ -614,7 +614,7 @@ public void ServerlessActivityAnnotationResolver_ResolveDeclarations_UsesWorkerP using EnvironmentVariableScope maxActivities = new("DTS_SERVERLESS_MAX_ACTIVITIES", "99"); // Act - ServerlessOptions options = ServerlessActivityAnnotationResolver.ResolveDeclarations(TaskHub) + ServerlessOptions options = ServerlessActivityDeclarationResolver.ResolveDeclarations(TaskHub) .Single(options => options.WorkerProfileId == "annotated-profile"); ServerlessActivityDeclaration declaration = ServerlessActivityConfiguration.BuildDeclaration( options, @@ -622,7 +622,7 @@ public void ServerlessActivityAnnotationResolver_ResolveDeclarations_UsesWorkerP // Assert declaration.WorkerProfileId.Should().Be("annotated-profile"); - declaration.ActivityNames.Should().Equal("ConfiguredRemoteHello", "AnnotatedRemoteHello", "ExplicitServerlessName"); + declaration.ActivityNames.Should().Equal("ConfiguredRemoteHello"); declaration.Image.ImageRef.Should().Be("example.com/repo/annotated-worker:latest"); declaration.Resources.Cpu.Should().Be("500m"); declaration.Resources.Memory.Should().Be("1024Mi"); @@ -633,16 +633,16 @@ public void ServerlessActivityAnnotationResolver_ResolveDeclarations_UsesWorkerP } [Fact] - public void ServerlessActivityAnnotationResolver_ResolveDeclaredActivityNames_UsesProfileConfigure() + public void ServerlessActivityDeclarationResolver_ResolveDeclaredActivityNames_UsesProfileConfigure() { // Arrange int before = AnnotatedWorkerProfile.ConfigureCallCount; // Act - string[] activityNames = ServerlessActivityAnnotationResolver.ResolveDeclaredActivityNames(TaskHub); + string[] activityNames = ServerlessActivityDeclarationResolver.ResolveDeclaredActivityNames(TaskHub); // Assert - activityNames.Should().Contain(["ConfiguredRemoteHello", "AnnotatedRemoteHello", "ExplicitServerlessName"]); + activityNames.Should().Contain("ConfiguredRemoteHello"); AnnotatedWorkerProfile.ConfigureCallCount.Should().BeGreaterThan(before); } @@ -781,26 +781,6 @@ public void Configure(ServerlessOptions options) } } - [DurableTask("AnnotatedRemoteHello")] - [ServerlessActivity("annotated-profile")] - sealed class AnnotatedRemoteHelloActivity : TaskActivity - { - public override Task RunAsync(TaskActivityContext context, string input) - { - return Task.FromResult(input); - } - } - - [DurableTask("DurableTaskNameIgnoredByServerlessAttribute")] - [ServerlessActivity("annotated-profile", Name = "ExplicitServerlessName")] - sealed class ExplicitNameServerlessActivity : TaskActivity - { - public override Task RunAsync(TaskActivityContext context, string input) - { - return Task.FromResult(input); - } - } - sealed class FakeServerlessActivitiesClient : IServerlessActivitiesClient { readonly Queue queuedSessions = new(); From d4373e6ff1558a6efd2cbe26b04e1353f29f143b Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 27 May 2026 12:32:54 -0700 Subject: [PATCH 40/81] Use attributes for serverless activity declarations --- samples/serverless/README.md | 6 +- samples/serverless/main-app/WorkerProfiles.cs | 6 +- .../ServerlessActivityDeclarationResolver.cs | 86 ++++++++++++++++++- .../Worker/Serverless/ServerlessOptions.cs | 41 +-------- .../ServerlessWorkerProfileAttribute.cs | 17 ++++ .../ServerlessActivitiesTests.cs | 32 +++---- 6 files changed, 129 insertions(+), 59 deletions(-) diff --git a/samples/serverless/README.md b/samples/serverless/README.md index 1fc4caab..65c7c102 100644 --- a/samples/serverless/README.md +++ b/samples/serverless/README.md @@ -30,8 +30,10 @@ docker push $image The main app uses `DefaultAzureCredential`; sign in with Azure CLI or configure another supported Azure identity before running it. After pushing the remote worker image, set `ContainerImage` in -`main-app/WorkerProfiles.cs` to the pushed image reference. The same profile -class declares the remote activity name, CPU, memory, and max concurrency. +`main-app/WorkerProfiles.cs` to the pushed image reference. The worker profile +class declares the image, CPU, memory, and max concurrency. The separate +`[ServerlessActivity(WorkerProfile = "default")]` declaration class declares the +remote activity name by class name. Update `main-app/appsettings.json` with your scheduler endpoint and task hub: diff --git a/samples/serverless/main-app/WorkerProfiles.cs b/samples/serverless/main-app/WorkerProfiles.cs index 73543bc8..e31f737f 100644 --- a/samples/serverless/main-app/WorkerProfiles.cs +++ b/samples/serverless/main-app/WorkerProfiles.cs @@ -14,6 +14,10 @@ public void Configure(ServerlessOptions options) options.Cpu = "1000m"; options.Memory = "2048Mi"; options.MaxConcurrentActivities = 1; - options.AddActivity(ServerlessTaskNames.RemoteHello); } } + +[ServerlessActivity(WorkerProfile = "default")] +internal sealed class RemoteHello +{ +} diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationResolver.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationResolver.cs index 268b1d0c..7f0ddaaf 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationResolver.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationResolver.cs @@ -12,6 +12,7 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; static class ServerlessActivityDeclarationResolver { static readonly Lazy Profiles = new(ScanProfiles, LazyThreadSafetyMode.ExecutionAndPublication); + static readonly Lazy Activities = new(ScanActivities, LazyThreadSafetyMode.ExecutionAndPublication); /// /// Resolves serverless declarations for the specified task hub. @@ -24,8 +25,11 @@ public static IReadOnlyList ResolveDeclarations(string taskHu ? throw new InvalidOperationException("Serverless activity declaration requires a task hub name.") : taskHub.Trim(); - ServerlessOptions[] declarations = Profiles.Value - .Select(profile => CreateOptions(normalizedTaskHub, profile)) + ProfileMetadata[] profiles = Profiles.Value; + Dictionary activitiesByProfile = ResolveActivitiesByProfile(profiles); + + ServerlessOptions[] declarations = profiles + .Select(profile => CreateOptions(normalizedTaskHub, profile, activitiesByProfile)) .Where(static options => ServerlessActivityConfiguration.ResolveActivityNames(options.ActivityNames).Length > 0) .ToArray(); @@ -67,7 +71,54 @@ static ProfileMetadata[] ScanProfiles() return profiles.Values.ToArray(); } - static ServerlessOptions CreateOptions(string taskHub, ProfileMetadata profile) + static ActivityMetadata[] ScanActivities() + { + List activities = []; + foreach (Type type in GetCandidateTypes()) + { + if (type.GetCustomAttribute() is not { } activity) + { + continue; + } + + activities.Add(new ActivityMetadata( + ResolveActivityName(type, activity), + ResolveWorkerProfileId(activity))); + } + + return activities.ToArray(); + } + + static Dictionary ResolveActivitiesByProfile(IReadOnlyCollection profiles) + { + Dictionary activitiesByProfile = Activities.Value + .GroupBy(static activity => activity.WorkerProfileId, StringComparer.Ordinal) + .ToDictionary( + static group => group.Key, + static group => group + .Select(static activity => activity.Name) + .Distinct(StringComparer.Ordinal) + .ToArray(), + StringComparer.Ordinal); + + HashSet profileIds = profiles + .Select(static profile => profile.WorkerProfileId) + .ToHashSet(StringComparer.Ordinal); + string[] missingProfiles = activitiesByProfile.Keys + .Where(profileId => !profileIds.Contains(profileId)) + .ToArray(); + if (missingProfiles.Length > 0) + { + throw new InvalidOperationException($"Serverless worker profile '{missingProfiles[0]}' is referenced by a serverless activity but is not declared."); + } + + return activitiesByProfile; + } + + static ServerlessOptions CreateOptions( + string taskHub, + ProfileMetadata profile, + Dictionary activitiesByProfile) { ServerlessOptions options = new() { @@ -76,6 +127,13 @@ static ServerlessOptions CreateOptions(string taskHub, ProfileMetadata profile) }; ConfigureProfile(profile.Type, options); + if (activitiesByProfile.TryGetValue(profile.WorkerProfileId, out string[]? activityNames)) + { + foreach (string activityName in activityNames) + { + options.ActivityNames.Add(activityName); + } + } return options; } @@ -136,7 +194,29 @@ static void ValidateActivityOwnership(IEnumerable declaration } } + static string ResolveActivityName(Type type, ServerlessActivityAttribute activity) + { + string activityName = string.IsNullOrWhiteSpace(activity.Name) + ? type.Name + : activity.Name.Trim(); + + return string.IsNullOrWhiteSpace(activityName) + ? throw new InvalidOperationException($"Serverless activity declaration '{type.FullName}' requires an activity name.") + : activityName; + } + + static string ResolveWorkerProfileId(ServerlessActivityAttribute activity) + { + return string.IsNullOrWhiteSpace(activity.WorkerProfile) + ? throw new InvalidOperationException("Serverless activity declaration requires a worker profile ID.") + : activity.WorkerProfile.Trim(); + } + sealed record ProfileMetadata( string WorkerProfileId, Type Type); + + sealed record ActivityMetadata( + string Name, + string WorkerProfileId); } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs index e3d07e11..1a6dd635 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs @@ -13,12 +13,6 @@ public sealed class ServerlessOptions /// internal const string DefaultWorkerProfileId = "default"; - /// - /// Gets the serverless activity names to declare. Remote workers report their registered - /// activities separately when they connect. - /// - public IList ActivityNames { get; } = new List(); - /// /// Gets or sets the task hub where the serverless activity declaration is stored. /// @@ -87,37 +81,8 @@ public sealed class ServerlessOptions public int MaxConcurrentActivities { get; set; } = 100; /// - /// Adds an activity name to the serverless declaration. - /// - /// The activity name to execute serverlessly. - /// The current options instance. - public ServerlessOptions AddActivity(string activityName) - { - if (string.IsNullOrWhiteSpace(activityName)) - { - throw new ArgumentException("Serverless activity name cannot be empty.", nameof(activityName)); - } - - this.ActivityNames.Add(activityName.Trim()); - return this; - } - - /// - /// Adds an activity type to the serverless declaration. + /// Gets the serverless activity names to declare. Remote workers report their registered + /// activities separately when they connect. /// - /// The activity type to execute serverlessly. - /// The current options instance. - public ServerlessOptions AddActivity() - where TActivity : class, ITaskActivity - { - return this.AddActivity(GetTaskName(typeof(TActivity))); - } - - static string GetTaskName(Type type) - { - Check.NotNull(type); - return Attribute.GetCustomAttribute(type, typeof(DurableTaskAttribute)) is DurableTaskAttribute { Name.Name: not null and not "" } attr - ? attr.Name.Name - : type.Name; - } + internal IList ActivityNames { get; } = new List(); } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerProfileAttribute.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerProfileAttribute.cs index 96162721..a2169563 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerProfileAttribute.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerProfileAttribute.cs @@ -25,3 +25,20 @@ public ServerlessWorkerProfileAttribute(string workerProfileId) /// public string WorkerProfileId { get; } } + +/// +/// Declares that an activity should run on a DTS-managed serverless worker profile. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class ServerlessActivityAttribute : Attribute +{ + /// + /// Gets or sets the activity name. If not specified, the annotated class name is used. + /// + public string? Name { get; set; } + + /// + /// Gets or sets the worker profile ID that owns this serverless activity. + /// + public string WorkerProfile { get; set; } = ServerlessOptions.DefaultWorkerProfileId; +} diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index dafaaad2..ea1d733e 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -43,18 +43,16 @@ public void ServerlessDeclarationContract_DoesNotExposeRemovedOptions() } [Fact] - public void ServerlessOptions_AddActivity_AddsStringAndTypedActivityNames() + public void ServerlessDeclarationContract_DoesNotExposeAddActivity() { // Arrange - ServerlessOptions options = new(); + Type optionsType = typeof(ServerlessOptions); - // Act - options - .AddActivity(" RemoteHello ") - .AddActivity(); - - // Assert - options.ActivityNames.Should().Equal("RemoteHello", "TypedRemoteHello"); + // Act/Assert + optionsType.GetProperty("ActivityNames").Should().BeNull(); + optionsType.GetMethod("AddActivity", [typeof(string)]).Should().BeNull(); + optionsType.GetMethods().Should().NotContain(method => + method.Name == "AddActivity" && method.IsGenericMethodDefinition); } [Fact] @@ -70,7 +68,7 @@ public async Task ServerlessActivityDeclarationHostedService_SendsDeclarationPay Memory = "1024Mi", MaxConcurrentActivities = 7, }; - options.AddActivity("RemoteHello"); + options.ActivityNames.Add("RemoteHello"); options.EnvironmentVariables.Add("CUSTOM_SETTING", "enabled"); options.Entrypoint.Add("/usr/bin/tini"); options.Entrypoint.Add("--"); @@ -605,7 +603,7 @@ public void EnableServerlessActivities_WhenRunningInServerlessWorker_Throws() } [Fact] - public void ServerlessActivityDeclarationResolver_ResolveDeclarations_UsesWorkerProfileConfigure() + public void ServerlessActivityDeclarationResolver_ResolveDeclarations_UsesServerlessActivityAttributes() { // Arrange using EnvironmentVariableScope image = new("DTS_SERVERLESS_ACTIVITY_IMAGE", "example.com/not-used:latest"); @@ -622,7 +620,7 @@ public void ServerlessActivityDeclarationResolver_ResolveDeclarations_UsesWorker // Assert declaration.WorkerProfileId.Should().Be("annotated-profile"); - declaration.ActivityNames.Should().Equal("ConfiguredRemoteHello"); + declaration.ActivityNames.Should().Equal(nameof(ConfiguredRemoteHello)); declaration.Image.ImageRef.Should().Be("example.com/repo/annotated-worker:latest"); declaration.Resources.Cpu.Should().Be("500m"); declaration.Resources.Memory.Should().Be("1024Mi"); @@ -633,7 +631,7 @@ public void ServerlessActivityDeclarationResolver_ResolveDeclarations_UsesWorker } [Fact] - public void ServerlessActivityDeclarationResolver_ResolveDeclaredActivityNames_UsesProfileConfigure() + public void ServerlessActivityDeclarationResolver_ResolveDeclaredActivityNames_UsesServerlessActivityAttributes() { // Arrange int before = AnnotatedWorkerProfile.ConfigureCallCount; @@ -642,7 +640,7 @@ public void ServerlessActivityDeclarationResolver_ResolveDeclaredActivityNames_U string[] activityNames = ServerlessActivityDeclarationResolver.ResolveDeclaredActivityNames(TaskHub); // Assert - activityNames.Should().Contain("ConfiguredRemoteHello"); + activityNames.Should().Contain(nameof(ConfiguredRemoteHello)); AnnotatedWorkerProfile.ConfigureCallCount.Should().BeGreaterThan(before); } @@ -777,10 +775,14 @@ public void Configure(ServerlessOptions options) options.Memory = "1024Mi"; options.MaxConcurrentActivities = 4; options.EnvironmentVariables["CUSTOM_ENV"] = "configured-value"; - options.AddActivity("ConfiguredRemoteHello"); } } + [ServerlessActivity(WorkerProfile = "annotated-profile")] + sealed class ConfiguredRemoteHello + { + } + sealed class FakeServerlessActivitiesClient : IServerlessActivitiesClient { readonly Queue queuedSessions = new(); From ae1f0d1c977e098165a857e879a913cd63649f3c Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 27 May 2026 19:34:48 -0700 Subject: [PATCH 41/81] Use AddActivity for serverless declarations --- CHANGELOG.md | 1 + samples/serverless/README.md | 8 +- samples/serverless/main-app/Activities.cs | 1 - samples/serverless/main-app/Orchestrators.cs | 3 +- samples/serverless/main-app/WorkerProfiles.cs | 7 +- samples/serverless/main-app/main-app.csproj | 1 + .../serverless/remote-worker/Activities.cs | 3 +- .../remote-worker/remote-worker.csproj | 1 + samples/serverless/shared/ActivityNames.cs | 9 ++ samples/serverless/shared/shared.csproj | 10 +++ .../ServerlessActivityDeclarationResolver.cs | 87 +------------------ .../Worker/Serverless/ServerlessOptions.cs | 28 ++++++ .../ServerlessWorkerProfileAttribute.cs | 19 +--- .../ServerlessActivitiesTests.cs | 38 +++----- 14 files changed, 80 insertions(+), 136 deletions(-) create mode 100644 samples/serverless/shared/ActivityNames.cs create mode 100644 samples/serverless/shared/shared.csproj diff --git a/CHANGELOG.md b/CHANGELOG.md index 946b327f..07f684f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Updated private preview serverless worker profile declarations to use `ServerlessOptions.AddActivity(...)`, and updated the serverless sample to share activity name constants between the main app and remote worker. ## v1.24.2 - Bump DI.Abstractions and Bcl.AsyncInterfaces to 9.0.1 ([#3433](https://github.com/microsoft/durabletask-dotnet/pull/3433)) (#723) diff --git a/samples/serverless/README.md b/samples/serverless/README.md index 65c7c102..895cd902 100644 --- a/samples/serverless/README.md +++ b/samples/serverless/README.md @@ -6,6 +6,7 @@ The sample is intentionally split into two projects: | Path | Purpose | | --- | --- | +| `shared/` | Defines activity name constants shared by the main app and remote worker. | | `main-app/` | Runs locally or in a normal app host. It declares the serverless activity and starts one hello orchestration. | | `remote-worker/` | Builds the container image that DTS starts inside a serverless sandbox. It contains the remote hello activity. | @@ -31,9 +32,10 @@ docker push $image The main app uses `DefaultAzureCredential`; sign in with Azure CLI or configure another supported Azure identity before running it. After pushing the remote worker image, set `ContainerImage` in `main-app/WorkerProfiles.cs` to the pushed image reference. The worker profile -class declares the image, CPU, memory, and max concurrency. The separate -`[ServerlessActivity(WorkerProfile = "default")]` declaration class declares the -remote activity name by class name. +class declares the image, CPU, memory, max concurrency, and serverless activity +names with `options.AddActivity(...)`. The main app and remote worker both use +the `shared/ActivityNames.cs` constants so the declaration and worker registration +stay in sync. Update `main-app/appsettings.json` with your scheduler endpoint and task hub: diff --git a/samples/serverless/main-app/Activities.cs b/samples/serverless/main-app/Activities.cs index 79dfaee2..ef749ea6 100644 --- a/samples/serverless/main-app/Activities.cs +++ b/samples/serverless/main-app/Activities.cs @@ -6,7 +6,6 @@ namespace Microsoft.DurableTask.Samples.Serverless.MainApp; internal static class ServerlessTaskNames { public const string LocalHello = "LocalHello"; - public const string RemoteHello = "RemoteHello"; public const string HelloOrchestrator = nameof(HelloOrchestrator); } diff --git a/samples/serverless/main-app/Orchestrators.cs b/samples/serverless/main-app/Orchestrators.cs index 294046d5..3b71dea5 100644 --- a/samples/serverless/main-app/Orchestrators.cs +++ b/samples/serverless/main-app/Orchestrators.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Microsoft.DurableTask; +using Microsoft.DurableTask.Samples.Serverless.Shared; namespace Microsoft.DurableTask.Samples.Serverless.MainApp; @@ -11,7 +12,7 @@ internal sealed class HelloOrchestrator : TaskOrchestrator public override async Task RunAsync(TaskOrchestrationContext context, string input) { string localResult = await context.CallActivityAsync(ServerlessTaskNames.LocalHello, input); - string remoteResult = await context.CallActivityAsync(ServerlessTaskNames.RemoteHello, input); + string remoteResult = await context.CallActivityAsync(ActivityNames.RemoteHello, input); return $"{localResult}; {remoteResult}"; } } diff --git a/samples/serverless/main-app/WorkerProfiles.cs b/samples/serverless/main-app/WorkerProfiles.cs index e31f737f..4691ccd0 100644 --- a/samples/serverless/main-app/WorkerProfiles.cs +++ b/samples/serverless/main-app/WorkerProfiles.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Microsoft.DurableTask.Worker.AzureManaged.Serverless; +using Microsoft.DurableTask.Samples.Serverless.Shared; namespace Microsoft.DurableTask.Samples.Serverless.MainApp; @@ -14,10 +15,6 @@ public void Configure(ServerlessOptions options) options.Cpu = "1000m"; options.Memory = "2048Mi"; options.MaxConcurrentActivities = 1; + options.AddActivity(ActivityNames.RemoteHello); } } - -[ServerlessActivity(WorkerProfile = "default")] -internal sealed class RemoteHello -{ -} diff --git a/samples/serverless/main-app/main-app.csproj b/samples/serverless/main-app/main-app.csproj index f3987d2a..94b110e6 100644 --- a/samples/serverless/main-app/main-app.csproj +++ b/samples/serverless/main-app/main-app.csproj @@ -17,6 +17,7 @@ + diff --git a/samples/serverless/remote-worker/Activities.cs b/samples/serverless/remote-worker/Activities.cs index b5ae5ebc..6fb44313 100644 --- a/samples/serverless/remote-worker/Activities.cs +++ b/samples/serverless/remote-worker/Activities.cs @@ -2,10 +2,11 @@ // Licensed under the MIT License. using Microsoft.DurableTask; +using Microsoft.DurableTask.Samples.Serverless.Shared; namespace Microsoft.DurableTask.Samples.Serverless.RemoteWorker; -[DurableTask("RemoteHello")] +[DurableTask(ActivityNames.RemoteHello)] internal sealed class RemoteHelloActivity : TaskActivity { public override Task RunAsync(TaskActivityContext context, string input) diff --git a/samples/serverless/remote-worker/remote-worker.csproj b/samples/serverless/remote-worker/remote-worker.csproj index c358c3cb..0446c05f 100644 --- a/samples/serverless/remote-worker/remote-worker.csproj +++ b/samples/serverless/remote-worker/remote-worker.csproj @@ -15,6 +15,7 @@ + diff --git a/samples/serverless/shared/ActivityNames.cs b/samples/serverless/shared/ActivityNames.cs new file mode 100644 index 00000000..e7b0f5ab --- /dev/null +++ b/samples/serverless/shared/ActivityNames.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Samples.Serverless.Shared; + +public static class ActivityNames +{ + public const string RemoteHello = "RemoteHello"; +} diff --git a/samples/serverless/shared/shared.csproj b/samples/serverless/shared/shared.csproj new file mode 100644 index 00000000..2dc57c0a --- /dev/null +++ b/samples/serverless/shared/shared.csproj @@ -0,0 +1,10 @@ + + + + net10.0 + enable + ServerlessShared + Microsoft.DurableTask.Samples.Serverless.Shared + + + diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationResolver.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationResolver.cs index 7f0ddaaf..f5b89cc0 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationResolver.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationResolver.cs @@ -7,12 +7,11 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; /// -/// Resolves serverless activity declarations from worker profile annotations. +/// Resolves serverless activity declarations from worker profile configuration. /// static class ServerlessActivityDeclarationResolver { static readonly Lazy Profiles = new(ScanProfiles, LazyThreadSafetyMode.ExecutionAndPublication); - static readonly Lazy Activities = new(ScanActivities, LazyThreadSafetyMode.ExecutionAndPublication); /// /// Resolves serverless declarations for the specified task hub. @@ -25,11 +24,8 @@ public static IReadOnlyList ResolveDeclarations(string taskHu ? throw new InvalidOperationException("Serverless activity declaration requires a task hub name.") : taskHub.Trim(); - ProfileMetadata[] profiles = Profiles.Value; - Dictionary activitiesByProfile = ResolveActivitiesByProfile(profiles); - - ServerlessOptions[] declarations = profiles - .Select(profile => CreateOptions(normalizedTaskHub, profile, activitiesByProfile)) + ServerlessOptions[] declarations = Profiles.Value + .Select(profile => CreateOptions(normalizedTaskHub, profile)) .Where(static options => ServerlessActivityConfiguration.ResolveActivityNames(options.ActivityNames).Length > 0) .ToArray(); @@ -71,54 +67,9 @@ static ProfileMetadata[] ScanProfiles() return profiles.Values.ToArray(); } - static ActivityMetadata[] ScanActivities() - { - List activities = []; - foreach (Type type in GetCandidateTypes()) - { - if (type.GetCustomAttribute() is not { } activity) - { - continue; - } - - activities.Add(new ActivityMetadata( - ResolveActivityName(type, activity), - ResolveWorkerProfileId(activity))); - } - - return activities.ToArray(); - } - - static Dictionary ResolveActivitiesByProfile(IReadOnlyCollection profiles) - { - Dictionary activitiesByProfile = Activities.Value - .GroupBy(static activity => activity.WorkerProfileId, StringComparer.Ordinal) - .ToDictionary( - static group => group.Key, - static group => group - .Select(static activity => activity.Name) - .Distinct(StringComparer.Ordinal) - .ToArray(), - StringComparer.Ordinal); - - HashSet profileIds = profiles - .Select(static profile => profile.WorkerProfileId) - .ToHashSet(StringComparer.Ordinal); - string[] missingProfiles = activitiesByProfile.Keys - .Where(profileId => !profileIds.Contains(profileId)) - .ToArray(); - if (missingProfiles.Length > 0) - { - throw new InvalidOperationException($"Serverless worker profile '{missingProfiles[0]}' is referenced by a serverless activity but is not declared."); - } - - return activitiesByProfile; - } - static ServerlessOptions CreateOptions( string taskHub, - ProfileMetadata profile, - Dictionary activitiesByProfile) + ProfileMetadata profile) { ServerlessOptions options = new() { @@ -127,14 +78,6 @@ static ServerlessOptions CreateOptions( }; ConfigureProfile(profile.Type, options); - if (activitiesByProfile.TryGetValue(profile.WorkerProfileId, out string[]? activityNames)) - { - foreach (string activityName in activityNames) - { - options.ActivityNames.Add(activityName); - } - } - return options; } @@ -194,29 +137,7 @@ static void ValidateActivityOwnership(IEnumerable declaration } } - static string ResolveActivityName(Type type, ServerlessActivityAttribute activity) - { - string activityName = string.IsNullOrWhiteSpace(activity.Name) - ? type.Name - : activity.Name.Trim(); - - return string.IsNullOrWhiteSpace(activityName) - ? throw new InvalidOperationException($"Serverless activity declaration '{type.FullName}' requires an activity name.") - : activityName; - } - - static string ResolveWorkerProfileId(ServerlessActivityAttribute activity) - { - return string.IsNullOrWhiteSpace(activity.WorkerProfile) - ? throw new InvalidOperationException("Serverless activity declaration requires a worker profile ID.") - : activity.WorkerProfile.Trim(); - } - sealed record ProfileMetadata( string WorkerProfileId, Type Type); - - sealed record ActivityMetadata( - string Name, - string WorkerProfileId); } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs index 1a6dd635..6c77c4e2 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Reflection; + namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; /// @@ -85,4 +87,30 @@ public sealed class ServerlessOptions /// activities separately when they connect. /// internal IList ActivityNames { get; } = new List(); + + /// + /// Adds an activity name to the serverless worker profile declaration. + /// + /// The activity name. + public void AddActivity(string activityName) + { + if (string.IsNullOrWhiteSpace(activityName)) + { + throw new ArgumentException("Serverless activity name cannot be empty.", nameof(activityName)); + } + + this.ActivityNames.Add(activityName.Trim()); + } + + /// + /// Adds an activity to the serverless worker profile declaration using its durable task name. + /// + /// The activity type. + public void AddActivity() where TActivity : class, ITaskActivity + { + Type activityType = typeof(TActivity); + DurableTaskAttribute? attribute = activityType.GetCustomAttribute(); + string? activityName = attribute?.Name.Name; + this.AddActivity(string.IsNullOrWhiteSpace(activityName) ? activityType.Name : activityName); + } } diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerProfileAttribute.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerProfileAttribute.cs index a2169563..22615120 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerProfileAttribute.cs +++ b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerProfileAttribute.cs @@ -4,7 +4,7 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; /// -/// Declares a serverless worker profile that DTS can start for annotated activities. +/// Declares a serverless worker profile that DTS can start for activities declared by the profile. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] public sealed class ServerlessWorkerProfileAttribute : Attribute @@ -25,20 +25,3 @@ public ServerlessWorkerProfileAttribute(string workerProfileId) /// public string WorkerProfileId { get; } } - -/// -/// Declares that an activity should run on a DTS-managed serverless worker profile. -/// -[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] -public sealed class ServerlessActivityAttribute : Attribute -{ - /// - /// Gets or sets the activity name. If not specified, the annotated class name is used. - /// - public string? Name { get; set; } - - /// - /// Gets or sets the worker profile ID that owns this serverless activity. - /// - public string WorkerProfile { get; set; } = ServerlessOptions.DefaultWorkerProfileId; -} diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index ea1d733e..0f979e0d 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -43,16 +43,19 @@ public void ServerlessDeclarationContract_DoesNotExposeRemovedOptions() } [Fact] - public void ServerlessDeclarationContract_DoesNotExposeAddActivity() + public void ServerlessDeclarationContract_ExposesProfileAddActivityOnly() { // Arrange Type optionsType = typeof(ServerlessOptions); + Type? activityAttributeType = typeof(ServerlessOptions).Assembly.GetType( + "Microsoft.DurableTask.Worker.AzureManaged.Serverless.ServerlessActivityAttribute"); // Act/Assert optionsType.GetProperty("ActivityNames").Should().BeNull(); - optionsType.GetMethod("AddActivity", [typeof(string)]).Should().BeNull(); - optionsType.GetMethods().Should().NotContain(method => + optionsType.GetMethod("AddActivity", [typeof(string)]).Should().NotBeNull(); + optionsType.GetMethods().Should().Contain(method => method.Name == "AddActivity" && method.IsGenericMethodDefinition); + activityAttributeType.Should().BeNull(); } [Fact] @@ -68,7 +71,7 @@ public async Task ServerlessActivityDeclarationHostedService_SendsDeclarationPay Memory = "1024Mi", MaxConcurrentActivities = 7, }; - options.ActivityNames.Add("RemoteHello"); + options.AddActivity("RemoteHello"); options.EnvironmentVariables.Add("CUSTOM_SETTING", "enabled"); options.Entrypoint.Add("/usr/bin/tini"); options.Entrypoint.Add("--"); @@ -175,7 +178,7 @@ public async Task ServerlessActivityDeclarationHostedService_OmitsEntrypointAndC TaskHub = TaskHub, ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", }; - options.ActivityNames.Add("RemoteHello"); + options.AddActivity("RemoteHello"); FakeServerlessActivitiesClient client = new(); ServerlessActivityDeclarationHostedService service = new( client, @@ -224,7 +227,7 @@ public async Task ServerlessActivityDeclarationHostedService_DoesNotRetryTransie TaskHub = TaskHub, ContainerImage = "example.com/repo/worker@sha256:abc", }; - options.ActivityNames.Add("RemoteHello"); + options.AddActivity("RemoteHello"); FakeServerlessActivitiesClient client = new() { TransientDeclarationFailures = 1 }; ServerlessActivityDeclarationHostedService service = new( client, @@ -603,7 +606,7 @@ public void EnableServerlessActivities_WhenRunningInServerlessWorker_Throws() } [Fact] - public void ServerlessActivityDeclarationResolver_ResolveDeclarations_UsesServerlessActivityAttributes() + public void ServerlessActivityDeclarationResolver_ResolveDeclarations_UsesWorkerProfileConfigure() { // Arrange using EnvironmentVariableScope image = new("DTS_SERVERLESS_ACTIVITY_IMAGE", "example.com/not-used:latest"); @@ -620,7 +623,7 @@ public void ServerlessActivityDeclarationResolver_ResolveDeclarations_UsesServer // Assert declaration.WorkerProfileId.Should().Be("annotated-profile"); - declaration.ActivityNames.Should().Equal(nameof(ConfiguredRemoteHello)); + declaration.ActivityNames.Should().Equal("ConfiguredRemoteHello"); declaration.Image.ImageRef.Should().Be("example.com/repo/annotated-worker:latest"); declaration.Resources.Cpu.Should().Be("500m"); declaration.Resources.Memory.Should().Be("1024Mi"); @@ -631,7 +634,7 @@ public void ServerlessActivityDeclarationResolver_ResolveDeclarations_UsesServer } [Fact] - public void ServerlessActivityDeclarationResolver_ResolveDeclaredActivityNames_UsesServerlessActivityAttributes() + public void ServerlessActivityDeclarationResolver_ResolveDeclaredActivityNames_UsesWorkerProfileConfigure() { // Arrange int before = AnnotatedWorkerProfile.ConfigureCallCount; @@ -640,7 +643,7 @@ public void ServerlessActivityDeclarationResolver_ResolveDeclaredActivityNames_U string[] activityNames = ServerlessActivityDeclarationResolver.ResolveDeclaredActivityNames(TaskHub); // Assert - activityNames.Should().Contain(nameof(ConfiguredRemoteHello)); + activityNames.Should().Contain("ConfiguredRemoteHello"); AnnotatedWorkerProfile.ConfigureCallCount.Should().BeGreaterThan(before); } @@ -753,15 +756,6 @@ public void UseServerlessWorker_MissingInjectedTaskHub_Throws() .WithMessage("DTS_TASK_HUB must be injected by DTS for serverless workers."); } - [DurableTask("TypedRemoteHello")] - sealed class TypedRemoteHelloActivity : TaskActivity - { - public override Task RunAsync(TaskActivityContext context, string input) - { - return Task.FromResult(input); - } - } - [ServerlessWorkerProfile("annotated-profile")] sealed class AnnotatedWorkerProfile : IServerlessWorkerProfile { @@ -775,14 +769,10 @@ public void Configure(ServerlessOptions options) options.Memory = "1024Mi"; options.MaxConcurrentActivities = 4; options.EnvironmentVariables["CUSTOM_ENV"] = "configured-value"; + options.AddActivity("ConfiguredRemoteHello"); } } - [ServerlessActivity(WorkerProfile = "annotated-profile")] - sealed class ConfiguredRemoteHello - { - } - sealed class FakeServerlessActivitiesClient : IServerlessActivitiesClient { readonly Queue queuedSessions = new(); From b6faacd591204880e3da96f159f3c1f03d364580 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 28 May 2026 16:34:26 -0700 Subject: [PATCH 42/81] Fail serverless worker without activities Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...TaskSchedulerServerlessWorkerExtensions.cs | 12 +++++++++ .../ServerlessActivitiesTests.cs | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs index ef17b71d..cc71d211 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -24,6 +24,10 @@ public static class DurableTaskSchedulerServerlessWorkerExtensions "EnableServerlessActivities is for declaring serverless activities from the coordinator app. " + "DTS serverless workers should use UseServerlessWorker instead."; + const string UseServerlessWorkerNoActivitiesErrorMessage = + "UseServerlessWorker requires at least one registered activity. " + + "Register an activity on this worker before starting the serverless worker."; + /// /// Enables annotation-based serverless activity declarations with DTS and excludes annotated /// serverless activities from local execution. @@ -54,6 +58,9 @@ public static IDurableTaskWorkerBuilder EnableServerlessActivities(this IDurable /// to declare and provision the serverless activity configuration. /// /// + /// The worker must register at least one activity; serverless workers without registered activities fail at startup. + /// + /// /// Required environment variables injected automatically by DTS: /// /// DTS_ENDPOINT — canonical scheduler endpoint @@ -115,6 +122,11 @@ static void ExcludeDeclaredServerlessActivitiesFromLocalExecution( static void IncludeOnlyRegisteredActivities(DurableTaskWorkerWorkItemFilters filters) { + if (filters.Activities.Count == 0) + { + throw new InvalidOperationException(UseServerlessWorkerNoActivitiesErrorMessage); + } + filters.Orchestrations = []; filters.ExcludedActivities = []; filters.Entities = []; diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index 0f979e0d..80626959 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -674,6 +674,31 @@ public async Task UseServerlessWorker_ConfiguresRegisteredActivityWorkerFilter() filters.Entities.Should().BeEmpty(); } + [Fact] + public async Task UseServerlessWorker_WithNoRegisteredActivities_FailsWhenWorkerFiltersAreResolved() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + mockBuilder.Object.UseServerlessWorker(); + + await using ServiceProvider provider = services.BuildServiceProvider(); + + // Act + Action act = () => provider + .GetRequiredService>() + .Get(Options.DefaultName); + + // Assert + act.Should().Throw() + .WithMessage("UseServerlessWorker requires at least one registered activity*"); + } + [Fact] public async Task UseServerlessWorker_ConfiguresSchedulerWithoutCredential() { From d71f765186a9ed39bc176dd03fb41146ff5f7cf1 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 28 May 2026 16:47:51 -0700 Subject: [PATCH 43/81] Harden serverless worker runtime defaults Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/serverless/main-app/WorkerProfiles.cs | 2 +- ...urableTaskSchedulerServerlessWorkerExtensions.cs | 13 +++++++++++++ .../ServerlessActivitiesTests.cs | 5 +++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/samples/serverless/main-app/WorkerProfiles.cs b/samples/serverless/main-app/WorkerProfiles.cs index 4691ccd0..ea41ecf4 100644 --- a/samples/serverless/main-app/WorkerProfiles.cs +++ b/samples/serverless/main-app/WorkerProfiles.cs @@ -11,7 +11,7 @@ internal sealed class DefaultServerlessWorkerProfile : IServerlessWorkerProfile { public void Configure(ServerlessOptions options) { - options.ContainerImage = "serverless-remote-worker:local"; + options.ContainerImage = Environment.GetEnvironmentVariable("DTS_SERVERLESS_CONTAINER_IMAGE") ?? "serverless-remote-worker:local"; options.Cpu = "1000m"; options.Memory = "2048Mi"; options.MaxConcurrentActivities = 1; diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs index cc71d211..3b41095f 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs @@ -85,6 +85,10 @@ public static IDurableTaskWorkerBuilder UseServerlessWorker(this IDurableTaskWor ApplyWorkerEnvironmentOverrides(options); }); + builder.Services.AddOptions(builder.Name) + .PostConfigure>((options, runtimeOptions) => + ConfigureServerlessWorkerConcurrency(options, runtimeOptions.Get(builder.Name))); + builder.Services.AddOptions(builder.Name) .PostConfigure(IncludeOnlyRegisteredActivities); @@ -132,6 +136,15 @@ static void IncludeOnlyRegisteredActivities(DurableTaskWorkerWorkItemFilters fil filters.Entities = []; } + static void ConfigureServerlessWorkerConcurrency( + DurableTaskWorkerOptions options, + ServerlessWorkerRuntimeOptions runtimeOptions) + { + options.Concurrency.MaximumConcurrentActivityWorkItems = runtimeOptions.MaxConcurrentActivities; + options.Concurrency.MaximumConcurrentOrchestrationWorkItems = 0; + options.Concurrency.MaximumConcurrentEntityWorkItems = 0; + } + static void ThrowIfServerlessWorkerRuntime() { if (IsServerlessWorkerSubstrate(Environment.GetEnvironmentVariable("DTS_SUBSTRATE"))) diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs index 80626959..abfda4be 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs @@ -653,6 +653,7 @@ public async Task UseServerlessWorker_ConfiguresRegisteredActivityWorkerFilter() // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope maxActivities = new("DTS_SERVERLESS_MAX_ACTIVITIES", "3"); ServiceCollection services = new(); services.Configure( Options.DefaultName, @@ -666,12 +667,16 @@ public async Task UseServerlessWorker_ConfiguresRegisteredActivityWorkerFilter() await using ServiceProvider provider = services.BuildServiceProvider(); DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); + DurableTaskWorkerOptions workerOptions = provider.GetRequiredService>().Get(Options.DefaultName); // Assert filters.Activities.Select(filter => filter.Name).Should().Equal("RemoteHello"); filters.ExcludedActivities.Should().BeEmpty(); filters.Orchestrations.Should().BeEmpty(); filters.Entities.Should().BeEmpty(); + workerOptions.Concurrency.MaximumConcurrentActivityWorkItems.Should().Be(3); + workerOptions.Concurrency.MaximumConcurrentOrchestrationWorkItems.Should().Be(0); + workerOptions.Concurrency.MaximumConcurrentEntityWorkItems.Should().Be(0); } [Fact] From c03448f0a821f38f025359fa68655ff9c3cb7fd6 Mon Sep 17 00:00:00 2001 From: wangbill Date: Mon, 1 Jun 2026 11:55:08 -0700 Subject: [PATCH 44/81] Introduce On-Demand Sandbox extension and samples Add a new Azure Managed On-Demand Sandbox extension and migrate sample code to an on-demand sandbox layout. Many serverless extension files and samples were renamed/moved to src/Extensions/AzureManagedOnDemandSandbox and samples/on-demand-sandbox (new client, worker, activity, options, runtime and proto definitions added). Tests and project files updated to reference the OnDemandSandbox variants. CHANGELOG.md and README.md updated to reflect the on-demand sandbox changes and sample restructuring. Removed the previous serverless-specific files that were replaced by the on-demand sandbox equivalents. --- CHANGELOG.md | 916 +++++++++--------- Microsoft.DurableTask.sln | 11 +- README.md | 4 +- .../README.md | 30 +- .../main-app/Activities.cs | 6 +- .../main-app/Orchestrators.cs | 6 +- samples/on-demand-sandbox/main-app/Program.cs | 106 ++ .../main-app/WorkerProfiles.cs | 31 + .../main-app/appsettings.json | 6 + .../main-app/main-app.csproj | 6 +- .../remote-worker/Activities.cs | 4 +- .../remote-worker/Containerfile | 4 +- .../remote-worker/Containerfile.dockerignore | 4 +- .../remote-worker/Program.cs | 4 +- .../remote-worker/remote-worker.csproj | 6 +- .../shared/ActivityNames.cs | 2 +- .../shared/shared.csproj | 4 +- samples/serverless/main-app/Program.cs | 69 -- samples/serverless/main-app/WorkerProfiles.cs | 20 - samples/serverless/main-app/appsettings.json | 6 - .../AzureManagedOnDemandSandbox.csproj} | 4 +- .../Client/OnDemandSandboxActivitiesClient.cs | 34 + ...emandSandboxActivitiesClientExtensions.cs} | 24 +- ...itiesClientServiceCollectionExtensions.cs} | 22 +- ...hedulerOnDemandSandboxWorkerExtensions.cs} | 131 ++- .../OnDemandSandbox/ISandboxWorkerProfile.cs | 16 + .../Worker/OnDemandSandbox/Logs.cs | 50 + ...OnDemandSandboxActivitiesClientAdapter.cs} | 68 +- .../OnDemandSandboxActivityConfiguration.cs} | 70 +- ...SandboxActivityDeclarationHostedService.cs | 104 ++ ...mandSandboxActivityDeclarationResolver.cs} | 46 +- .../OnDemandSandboxActivityTracker.cs} | 6 +- ...ctivityWorkerRegistrationHostedService.cs} | 74 +- .../OnDemandSandboxOptions.cs} | 40 +- .../OnDemandSandboxWorkerProfileAttribute.cs | 27 + .../OnDemandSandboxWorkerRuntimeOptions.cs} | 28 +- .../Client/ServerlessActivitiesClient.cs | 34 - .../Serverless/IServerlessWorkerProfile.cs | 16 - .../Worker/Serverless/Logs.cs | 50 - ...verlessActivityDeclarationHostedService.cs | 104 -- .../ServerlessWorkerProfileAttribute.cs | 27 - ...on_demand_sandbox_activities_service.proto | 95 ++ src/Grpc/serverless_activities_service.proto | 93 -- .../AzureManagedOnDemandSandbox.Tests.csproj} | 2 +- ...SandboxActivitiesClientExtensionsTests.cs} | 32 +- .../OnDemandSandboxActivitiesTests.cs} | 340 +++---- 46 files changed, 1407 insertions(+), 1375 deletions(-) rename samples/{serverless => on-demand-sandbox}/README.md (60%) rename samples/{serverless => on-demand-sandbox}/main-app/Activities.cs (72%) rename samples/{serverless => on-demand-sandbox}/main-app/Orchestrators.cs (75%) create mode 100644 samples/on-demand-sandbox/main-app/Program.cs create mode 100644 samples/on-demand-sandbox/main-app/WorkerProfiles.cs create mode 100644 samples/on-demand-sandbox/main-app/appsettings.json rename samples/{serverless => on-demand-sandbox}/main-app/main-app.csproj (82%) rename samples/{serverless => on-demand-sandbox}/remote-worker/Activities.cs (77%) rename samples/{serverless => on-demand-sandbox}/remote-worker/Containerfile (87%) rename samples/{serverless => on-demand-sandbox}/remote-worker/Containerfile.dockerignore (81%) rename samples/{serverless => on-demand-sandbox}/remote-worker/Program.cs (87%) rename samples/{serverless => on-demand-sandbox}/remote-worker/remote-worker.csproj (76%) rename samples/{serverless => on-demand-sandbox}/shared/ActivityNames.cs (72%) rename samples/{serverless => on-demand-sandbox}/shared/shared.csproj (53%) delete mode 100644 samples/serverless/main-app/Program.cs delete mode 100644 samples/serverless/main-app/WorkerProfiles.cs delete mode 100644 samples/serverless/main-app/appsettings.json rename src/Extensions/{AzureManagedServerless/AzureManagedServerless.csproj => AzureManagedOnDemandSandbox/AzureManagedOnDemandSandbox.csproj} (77%) create mode 100644 src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs rename src/Extensions/{AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs => AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientExtensions.cs} (56%) rename src/Extensions/{AzureManagedServerless/Client/ServerlessActivitiesClientServiceCollectionExtensions.cs => AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs} (54%) rename src/Extensions/{AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs => AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs} (58%) create mode 100644 src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/ISandboxWorkerProfile.cs create mode 100644 src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/Logs.cs rename src/Extensions/{AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs => AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivitiesClientAdapter.cs} (51%) rename src/Extensions/{AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs => AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs} (63%) create mode 100644 src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationHostedService.cs rename src/Extensions/{AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationResolver.cs => AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs} (58%) rename src/Extensions/{AzureManagedServerless/Worker/Serverless/ServerlessActivityTracker.cs => AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityTracker.cs} (84%) rename src/Extensions/{AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs => AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs} (77%) rename src/Extensions/{AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs => AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs} (64%) create mode 100644 src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs rename src/Extensions/{AzureManagedServerless/Worker/Serverless/ServerlessWorkerRuntimeOptions.cs => AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs} (54%) delete mode 100644 src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClient.cs delete mode 100644 src/Extensions/AzureManagedServerless/Worker/Serverless/IServerlessWorkerProfile.cs delete mode 100644 src/Extensions/AzureManagedServerless/Worker/Serverless/Logs.cs delete mode 100644 src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs delete mode 100644 src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerProfileAttribute.cs create mode 100644 src/Grpc/on_demand_sandbox_activities_service.proto delete mode 100644 src/Grpc/serverless_activities_service.proto rename test/Extensions/{AzureManagedServerless.Tests/AzureManagedServerless.Tests.csproj => AzureManagedOnDemandSandbox.Tests/AzureManagedOnDemandSandbox.Tests.csproj} (86%) rename test/Extensions/{AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs => AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientExtensionsTests.cs} (68%) rename test/Extensions/{AzureManagedServerless.Tests/ServerlessActivitiesTests.cs => AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs} (68%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07f684f3..42edcb22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,458 +1,458 @@ -# Changelog - -## Unreleased - -- Updated private preview serverless worker profile declarations to use `ServerlessOptions.AddActivity(...)`, and updated the serverless sample to share activity name constants between the main app and remote worker. - -## v1.24.2 -- Bump DI.Abstractions and Bcl.AsyncInterfaces to 9.0.1 ([#3433](https://github.com/microsoft/durabletask-dotnet/pull/3433)) (#723) -- Validate UseWorkItemFilters names against registered tasks at worker build time ([#719](https://github.com/microsoft/durabletask-dotnet/pull/719)) -- Bump `Microsoft.Extensions.DependencyInjection.Abstractions` from 8.0.2 to 9.0.1 (and `Microsoft.Bcl.AsyncInterfaces` from 8.0.0 to 9.0.1, which the former transitively floors at 9.0.1) to align with the floor declared by `Microsoft.Azure.WebJobs 3.0.45 -> Microsoft.Extensions.Logging.Abstractions 9.0.1`. Fixes NU1605 in downstream Azure Functions Worker isolated apps consuming `Microsoft.DurableTask.Extensions.AzureBlobPayloads` ([Azure/azure-functions-durable-extension#3433](https://github.com/Azure/azure-functions-durable-extension/issues/3433)). -- Validate explicit `UseWorkItemFilters(filters)` filter names against the worker's `DurableTaskRegistry`. Filters that reference an orchestration, activity, or entity name not registered with the worker now throw `OptionsValidationException` at worker startup instead of silently waiting for work items that will never arrive. No customer-side validation call is required. ([#719](https://github.com/microsoft/durabletask-dotnet/pull/719)) - -## 1.24.1 -- Add retry to grpc calls that failed due to transient errors by @sophiatev ([#714](https://github.com/microsoft/durabletask-dotnet/pull/714)) - -## v1.24.0 -- Harden grpc worker and client against silent disconnects by @berndverst ([#708](https://github.com/microsoft/durabletask-dotnet/pull/708)) -- Preserve late events after continue-as-new by @berndverst ([#711](https://github.com/microsoft/durabletask-dotnet/pull/711)) -- Fix inprocesstesthost continueasnew stuck-instance race condition by @bachuv ([#707](https://github.com/microsoft/durabletask-dotnet/pull/707)) -- Fix continue-as-new race condition at inprocesstesthost by @nytian ([#703](https://github.com/microsoft/durabletask-dotnet/pull/703)) -- Add opt-in timeout to purgeinstancesfilter for partial purge by @yunchuwang ([#680](https://github.com/microsoft/durabletask-dotnet/pull/680)) - -## v1.23.3 -- fix: revert shared framework packages to 8.x for net8 Functions host compatibility ([#698](https://github.com/microsoft/durabletask-dotnet/pull/698)) -- Release v1.23.2 ([#693](https://github.com/microsoft/durabletask-dotnet/pull/693)) - -## v1.23.2 -- fix: improve large payload error handling — better error message and prevent infinite retry and fix conflict with auto chunking ([#691](https://github.com/microsoft/durabletask-dotnet/pull/691)) -- Bump dotnet-sdk from 10.0.103 to 10.0.201 ([#673](https://github.com/microsoft/durabletask-dotnet/pull/673)) -- Bump Microsoft.Azure.DurableTask.Core from 3.7.0 to 3.7.1 ([#685](https://github.com/microsoft/durabletask-dotnet/pull/685)) -- feat(copilot): add evidence-based Copilot customizations ([#690](https://github.com/microsoft/durabletask-dotnet/pull/690)) - -## v1.23.1 -- Fix CHANGELOG line ending preservation in Prepare Release workflow ([#687](https://github.com/microsoft/durabletask-dotnet/pull/687)) -- Add Prepare Release GitHub Action for automated release kickoff ([#686](https://github.com/microsoft/durabletask-dotnet/pull/686)) -- Add ContinueAsNewOptions with NewVersion support ([#682](https://github.com/microsoft/durabletask-dotnet/pull/682)) -- Fix concurrent timer race condition in InMemoryOrchestrationService ([#678](https://github.com/microsoft/durabletask-dotnet/pull/678)) - -## v1.23.0 -- Generate extension methods in task namespace instead of Microsoft.DurableTask ([#538](https://github.com/microsoft/durabletask-dotnet/pull/538)) -- Fix #668: Change work item filters from auto opt-in to explicit opt-in ([#669](https://github.com/microsoft/durabletask-dotnet/pull/669)) -- Add `ReplaySafeLoggerFactory` for context wrappers ([#670](https://github.com/microsoft/durabletask-dotnet/pull/670)) -- Add NuGet publish job for Microsoft.DurableTask.Analyzers ([#662](https://github.com/microsoft/durabletask-dotnet/pull/662)) -- Bump Azure.Identity from 1.17.1 to 1.18.0 ([#656](https://github.com/microsoft/durabletask-dotnet/pull/656)) -- Bump Microsoft.Azure.Functions.Worker.Extensions.DurableTask from 1.12.1 to 1.15.0 ([#658](https://github.com/microsoft/durabletask-dotnet/pull/658)) -- Add missing input validation to SuspendInstanceAsync and ResumeInstanceAsync ([#652](https://github.com/microsoft/durabletask-dotnet/pull/652)) -- Add ExportHistory package to NuGet publish pipeline ([#651](https://github.com/microsoft/durabletask-dotnet/pull/651)) -- Add OpenTelemetry sample and update deps ([#637](https://github.com/microsoft/durabletask-dotnet/pull/637)) -- Fix build warnings and clean up exception message ([#647](https://github.com/microsoft/durabletask-dotnet/pull/647)) - -## v1.22.0 -- Changing the default dedupe statuses behavior by sophiatev ([#622](https://github.com/microsoft/durabletask-dotnet/pull/622)) -- Bump Analyzers package version to 1.22.0 stable release (from 0.3.0) -- Add DURABLE0011: ContinueAsNew warning for unbounded orchestration loops ([#660](https://github.com/microsoft/durabletask-dotnet/pull/660)) - -## 1.21.0 -- Introduce WorkItemFilters into worker flow by halspang ([#616](https://github.com/microsoft/durabletask-dotnet/pull/616)) -- Fix Analyzers treating passed in variable argument name as null by wangbill ([#640](https://github.com/microsoft/durabletask-dotnet/pull/640)) -- Move DURABLE0009/0010 from Unshipped to Shipped for v0.3.0 by cgillum ([#641](https://github.com/microsoft/durabletask-dotnet/pull/641)) - -## 1.20.1 -- Fix GrpcChannel handle leak in AzureManaged backendby nytian ([#629](https://github.com/microsoft/durabletask-dotnet/pull/629)) - -## 1.20.0 -- Partial orchestration workitem completion support (merge after next dts dp release) by wangbill ([#514](https://github.com/microsoft/durabletask-dotnet/pull/514)) -- Export history job by wangbill ([#494](https://github.com/microsoft/durabletask-dotnet/pull/494)) -- Add dependency injection support to durabletasktesthost by Naiyuan Tian ([#613](https://github.com/microsoft/durabletask-dotnet/pull/613)) - -## v1.19.1 -- Throw an `InvalidOperationException` for purge requests on running orchestrations by sophiatev ([#611](https://github.com/microsoft/durabletask-dotnet/pull/611)) -- Validate c# identifiers in durabletask source generator by Copilot ([#578](https://github.com/microsoft/durabletask-dotnet/pull/578)) -- Document orchestration discovery and method probing behavior in analyzers by Copilot ([#594](https://github.com/microsoft/durabletask-dotnet/pull/594)) - -## v1.19.0 -- Extended sessions for entities in .net isolated by sophiatev ([#507](https://github.com/microsoft/durabletask-dotnet/pull/507)) -- Adding the ability to specify tags and a retry policy for suborchestrations by sophiatev ([#603](https://github.com/microsoft/durabletask-dotnet/pull/603)) -- Improve durabletask source generator detection and add optional project type configuration by Copilot ([#575](https://github.com/microsoft/durabletask-dotnet/pull/575)) -- Add timeprovider support to orchestration analyzer by Copilot ([#573](https://github.com/microsoft/durabletask-dotnet/pull/573)) -- Expand azure functions smoke tests to cover source generator scenarios by Copilot ([#604](https://github.com/microsoft/durabletask-dotnet/pull/604)) -- Fix "syntaxtree is not part of the compilation" exception in orchestration analyzers by Copilot ([#588](https://github.com/microsoft/durabletask-dotnet/pull/588)) -- Add waitforexternalevent overload with timeout and cancellation token by Copilot ([#555](https://github.com/microsoft/durabletask-dotnet/pull/555)) -- Fix source generator for void-returning activity functions by Copilot ([#554](https://github.com/microsoft/durabletask-dotnet/pull/554)) - -## v1.18.2 -- Add copy constructors to TaskOptions and sub-classes by halspang ([#587](https://github.com/microsoft/durabletask-dotnet/pull/587)) -- Change FunctionNotFound analyzer severity to Info for cross-assembly scenarios by Copilot ([#584](https://github.com/microsoft/durabletask-dotnet/pull/584)) -- Add Roslyn analyzer for non-contextual logger usage in orchestrations (DURABLE0010) by Copilot ([#553](https://github.com/microsoft/durabletask-dotnet/pull/553)) -- Add specific logging categories for Worker.Grpc and orchestration logs with backward-compatible opt-in by Copilot ([#583](https://github.com/microsoft/durabletask-dotnet/pull/583)) -- Fix flaky integration test race condition in dedup status check by Copilot ([#579](https://github.com/microsoft/durabletask-dotnet/pull/579)) -- Add analyzer to suggest input parameter binding over GetInput() by Copilot ([#550](https://github.com/microsoft/durabletask-dotnet/pull/550)) -- Add strongly-typed external events with DurableEventAttribute by Copilot ([#549](https://github.com/microsoft/durabletask-dotnet/pull/549)) -- Fix orchestration analyzer to detect non-function orchestrations correctly by Copilot ([#572](https://github.com/microsoft/durabletask-dotnet/pull/572)) -- Fix race condition in WaitForInstanceAsync causing intermittent test failures by Copilot ([#574](https://github.com/microsoft/durabletask-dotnet/pull/574)) -- Add HelpLinkUri to Roslyn analyzer diagnostics by Copilot ([#548](https://github.com/microsoft/durabletask-dotnet/pull/548)) -- Add DateTimeOffset.Now and DateTimeOffset.UtcNow detection to Roslyn analyzer by Copilot ([#547](https://github.com/microsoft/durabletask-dotnet/pull/547)) -- Bump Google.Protobuf from 3.33.1 to 3.33.2 by dependabot[bot] ([#569](https://github.com/microsoft/durabletask-dotnet/pull/569)) -- Add integration test coverage for Suspend/Resume operations by Copilot ([#546](https://github.com/microsoft/durabletask-dotnet/pull/546)) -- Bump coverlet.collector from 6.0.2 to 6.0.4 by dependabot[bot] ([#527](https://github.com/microsoft/durabletask-dotnet/pull/527)) -- Bump FluentAssertions from 6.12.1 to 6.12.2 by dependabot[bot] ([#528](https://github.com/microsoft/durabletask-dotnet/pull/528)) -- Add Azure Functions smoke tests with Docker CI automation by Copilot ([#545](https://github.com/microsoft/durabletask-dotnet/pull/545)) -- Bump dotnet-sdk from 10.0.100 to 10.0.101 by dependabot[bot] ([#568](https://github.com/microsoft/durabletask-dotnet/pull/568)) -- Add scheduled auto-closure for stale "Needs Author Feedback" issues by Copilot ([#566](https://github.com/microsoft/durabletask-dotnet/pull/566)) - -## v1.18.1 -- Support dedup status when starting orchestration by wangbill ([#542](https://github.com/microsoft/durabletask-dotnet/pull/542)) -- Add 404 exception handling in blobpayloadstore.downloadasync by Copilot ([#534](https://github.com/microsoft/durabletask-dotnet/pull/534)) -- Bump analyzers version to 0.2.0 by Copilot ([#552](https://github.com/microsoft/durabletask-dotnet/pull/552)) -- Add integration tests for exception type handling by Copilot ([#544](https://github.com/microsoft/durabletask-dotnet/pull/544)) -- Add roslyn analyzer to detect calls to non-existent functions (name mismatch) by Copilot ([#530](https://github.com/microsoft/durabletask-dotnet/pull/530)) -- Remove preview suffix by Copilot ([#541](https://github.com/microsoft/durabletask-dotnet/pull/541)) -- Add xml documentation with see cref links to generated code for better ide navigation by Copilot ([#535](https://github.com/microsoft/durabletask-dotnet/pull/535)) -- Add entity source generation support for durable functions by Copilot ([#533](https://github.com/microsoft/durabletask-dotnet/pull/533)) - -## v1.18.0 -- Add taskentity support to durabletasksourcegenerator by Copilot ([#517](https://github.com/microsoft/durabletask-dotnet/pull/517)) -- Bump azure.identity by dependabot[bot] ([#525](https://github.com/microsoft/durabletask-dotnet/pull/525)) -- Bump google.protobuf by dependabot[bot] ([#529](https://github.com/microsoft/durabletask-dotnet/pull/529)) -- Configure dependabot for dotnet-sdk updates by Tomer Rosenthal ([#524](https://github.com/microsoft/durabletask-dotnet/pull/524)) -- Add code review guidelines to copilot-instructions.md by Copilot ([#522](https://github.com/microsoft/durabletask-dotnet/pull/522)) -- Remove webapi sample by sophiatev ([#520](https://github.com/microsoft/durabletask-dotnet/pull/520)) -- Fix functioncontext check and polymorphic type conversions in activity analyzer by Naiyuan Tian ([#506](https://github.com/microsoft/durabletask-dotnet/pull/506)) -- Align waitforexternalevent waiter picking order to lifo by wangbill ([#509](https://github.com/microsoft/durabletask-dotnet/pull/509)) -- Update project to support .net 6.0 alongside .net 8.0 and .net 10 by Tomer Rosenthal ([#512](https://github.com/microsoft/durabletask-dotnet/pull/512)) -- Update project to target .net 8.0 and .net 10 and upgrade dependencies by Tomer Rosenthal ([#510](https://github.com/microsoft/durabletask-dotnet/pull/510)) -- Support worker features announcement by wangbill ([#502](https://github.com/microsoft/durabletask-dotnet/pull/502)) -- Introduce custom copilot review instructions by halspang ([#503](https://github.com/microsoft/durabletask-dotnet/pull/503)) -- Add API to get orchestration history ([#516](https://github.com/microsoft/durabletask-dotnet/pull/516)) - -## v1.17.1 -- Fix Worker Registry and Add Documentation Notes by nytian in [#462](https://github.com/microsoft/durabletask-dotnet/pull/495) -- Initial attempt to fix carryover events issue on continue-as-new by cgillum in [#496](https://github.com/microsoft/durabletask-dotnet/pull/496) -- Fix encoding of entity unlock events by sebastianburckhardt in [#462](https://github.com/microsoft/durabletask-dotnet/pull/462) - -## v1.17.0 --Add Microsoft.DurableTask.Extensions.AzureBlobPayloads to nuget publish by YunchuWang in [#488](https://github.com/microsoft/durabletask-dotnet/pull/488) --Add API for In-process Testing and Add Class-Syntax Integration Tests by nytian in [#476](https://github.com/microsoft/durabletask-dotnet/pull/476) --Fix Purge Instance Comments by sophiatev in [#489](https://github.com/microsoft/durabletask-dotnet/pull/489) --Fix ServiceCollectionExtensions.AddDurableTaskClient by sophiatev in [#490](https://github.com/microsoft/durabletask-dotnet/pull/490) --Update zuremanaged sdks to official version by YunchuWang in [#493](https://github.com/microsoft/durabletask-dotnet/pull/493) --Add Rewind to .NET isolated by sophiatev in [#479](https://github.com/microsoft/durabletask-dotnet/pull/479) --Add tags field to CompleteOrchestratorAction by sophiatev in [#492](https://github.com/microsoft/durabletask-dotnet/pull/492) - -## v1.16.2 -- Generate changelog script + update changelog for v1.16.1 by wangbill ([#486](https://github.com/microsoft/durabletask-dotnet/pull/486)) -- Remove unnecessary project reference to grpc.azuremanagedbackend in azureblobpayloads.csproj by wangbill ([#485](https://github.com/microsoft/durabletask-dotnet/pull/485)) -- Large payload azure blob externalization support by wangbill ([#468](https://github.com/microsoft/durabletask-dotnet/pull/468)) - -## v1.16.1 -- Include exception properties in failure details when orchestration throws directly by Naiyuan Tian ([#482](https://github.com/microsoft/durabletask-dotnet/pull/482)) -- Set low priority for scheduled runs by Daniel Castro ([#477](https://github.com/microsoft/durabletask-dotnet/pull/477)) - -## v1.16.0 -- Include Exception Properties at FailureDetails by nytian in([#474](https://github.com/microsoft/durabletask-dotnet/pull/474)) - -## v1.15.1 -- Add version check to activities by @halspang in ([#472](https://github.com/microsoft/durabletask-dotnet/pull/472)) - -## v1.15.0 -- Abandon workitem if processing workitem failed by @YunchuWang in ([#467](https://github.com/microsoft/durabletask-dotnet/pull/467)) -- Extended Sessions for Isolated (Orchestrations) by @sophiatev in ([#449](https://github.com/microsoft/durabletask-dotnet/pull/449)) - -## v1.14.0 -- Add RestartAsync API Support at DurableTaskClient ([#456](https://github.com/microsoft/durabletask-dotnet/pull/456)) - -## v1.13.0 -- Add orchestration execution tracing ([#441](https://github.com/microsoft/durabletask-dotnet/pull/441)) - -## v1.12.0 - -- Activity tag support ([#426](https://github.com/microsoft/durabletask-dotnet/pull/426)) -- Adding Analyzer to build and release ([#444](https://github.com/microsoft/durabletask-dotnet/pull/444)) -- Add ability to filter orchestrations at worker ([#443](https://github.com/microsoft/durabletask-dotnet/pull/443)) -- Removing breaking change for TaskOptions ([#446](https://github.com/microsoft/durabletask-dotnet/pull/446)) -- Expose gRPC retry options in Azure Managed extensions ([#447](https://github.com/microsoft/durabletask-dotnet/pull/447)) - -## v1.11.0 - -- Add New Property Properties to TaskOrchestrationContext ([#415](https://github.com/microsoft/durabletask-dotnet/pull/415)) -- Add automatic retry on gateway timeout in `GrpcDurableTaskClient.WaitForInstanceCompletionAsync` ([#412](https://github.com/microsoft/durabletask-dotnet/pull/412)) -- Add specific logging for NotFound error on worker connection ([#413](https://github.com/microsoft/durabletask-dotnet/pull/413)) -- Add user agent header to gRPC called ([#417](https://github.com/microsoft/durabletask-dotnet/pull/417)) -- Enrich User-Agent Header in gRPC Metadata to indicate Client or Worker as caller ([#421](https://github.com/microsoft/durabletask-dotnet/pull/421)) -- Change DTS user agent metadata to avoid overlap with gRPC user agent ([#423](https://github.com/microsoft/durabletask-dotnet/pull/423)) -- Add extension methods for registering entities by type ([#427](https://github.com/microsoft/durabletask-dotnet/pull/427)) -- Add TaskVersion and utilize it for version overrides when starting orchestrations ([#416](https://github.com/microsoft/durabletask-dotnet/pull/416)) -- Update sub-orchestration default versioning ([#437](https://github.com/microsoft/durabletask-dotnet/pull/437)) -- Distributed Tracing for Entities (Isolated) ([#404](https://github.com/microsoft/durabletask-dotnet/pull/404)) - -## v1.10.0 - -- Update DurableTask.Core to v3.1.0 and Bump version to v1.10.0 by @nytian in ([#411](https://github.com/microsoft/durabletask-dotnet/pull/411)) - -## v1.9.1 - -- Add basic orchestration and activity execution logs by @cgillum in ([#405](https://github.com/microsoft/durabletask-dotnet/pull/405)) -- Add default version in `TaskOrchestrationContext` by @halspang in ([#408](https://github.com/microsoft/durabletask-dotnet/pull/408)) - -## v1.9.0 - -- Fix schedule sample logging setup by @YunchuWang in ([#392](https://github.com/microsoft/durabletask-dotnet/pull/392)) -- Introduce versioning to the DurableTaskClient by @halspang in ([#393](https://github.com/microsoft/durabletask-dotnet/pull/393)) -- Support for local credential types for DTS by @cgillum in ([#396](https://github.com/microsoft/durabletask-dotnet/pull/396)) -- Add utilities for easier versioning usage by @halspang in ([#394](https://github.com/microsoft/durabletask-dotnet/pull/394)) -- Add tags to CreateInstanceRequest by @torosent in ([#397](https://github.com/microsoft/durabletask-dotnet/pull/397)) -- Partial Purge Support by @YunchuWang in ([#400](https://github.com/microsoft/durabletask-dotnet/pull/400)) -- Dts Grpc client retry support by @YunchuWang in ([#403](https://github.com/microsoft/durabletask-dotnet/pull/403)) -- Introduce orchestration versioning into worker by @halspang in ([#401](https://github.com/microsoft/durabletask-dotnet/pull/401)) - -## v1.8.1 - -- Add timeout to gRPC workitem streaming ([#390](https://github.com/microsoft/durabletask-dotnet/pull/390)) - -## v1.8.0 - -- Add Schedule Support for Durable Task Framework ([#368](https://github.com/microsoft/durabletask-dotnet/pull/368)) -- Fixes and improvements - -## v1.7.0 - -- Add parent trace context information when scheduling an orchestration ([#358](https://github.com/microsoft/durabletask-dotnet/pull/358)) - -## v1.6.0 - -- Added new preview packages, `Microsoft.DurableTask.Client.AzureManaged` and `Microsoft.DurableTask.Worker.AzureManaged` -- Move to Central Package Management ([#373](https://github.com/microsoft/durabletask-dotnet/pull/373)) - -### Microsoft.DurableTask.Client - -- Add new `IDurableTaskClientBuilder AddDurableTaskClient(IServiceCollection, string?)` API - -### Microsoft.DurableTask.Worker - -- Add new `IDurableTaskWorkerBuilder AddDurableTaskWorker(IServiceCollection, string?)` API -- Add support for work item history streaming - -### Microsoft.DurableTask.Worker.Grpc - -- Provide entity support for direct grpc connections to DTS ([#369](https://github.com/microsoft/durabletask-dotnet/pull/369)) - -### Microsoft.DurableTask.Grpc - -- Replace submodule for proto files with download script for easier maintenance -- Update to latest proto files - -## v1.5.0 - -- Implement work item completion tokens for standalone worker scenarios ([#359](https://github.com/microsoft/durabletask-dotnet/pull/359)) -- Support for worker concurrency configuration ([#359](https://github.com/microsoft/durabletask-dotnet/pull/359)) -- Bump System.Text.Json to 6.0.10 -- Initial support for the Azure-managed [Durable Task Scheduler](https://techcommunity.microsoft.com/blog/appsonazureblog/announcing-limited-early-access-of-the-durable-task-scheduler-for-azure-durable-/4286526) preview. - -## v1.4.0 - -- Microsoft.Azure.DurableTask.Core dependency increased to `3.0.0` - -## v1.3.0 - -### Microsoft.DurableTask.Abstractions - -- Add `RetryPolicy.Handle` property to allow for exception filtering on retries ([#314](https://github.com/microsoft/durabletask-dotnet/pull/314)) - -## v1.2.4 - -- Microsoft.Azure.DurableTask.Core dependency increased to `2.17.1` - -## v1.2.3 - -### Microsoft.DurableTask.Client - -- Fix filter not being passed along in `PurgeAllInstancesAsync` (https://github.com/microsoft/durabletask-dotnet/pull/289) - -### Microsoft.DurableTask.Abstractions - -- Enable inner exception detail propagation in `TaskFailureDetails` ([#290](https://github.com/microsoft/durabletask-dotnet/pull/290)) -- Microsoft.Azure.DurableTask.Core dependency increased to `2.17.0` - -## v1.2.2 - -### Microsoft.DurableTask.Abstractions - -- Fix `TaskFailureDetails.IsCausedBy` to support custom exceptions and 3rd party exceptions ([#273](https://github.com/microsoft/durabletask-dotnet/pull/273)) -- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.2` - -### Microsoft.DurableTask.Client - -- Fix typo in `PurgeInstanceAsync` in `DurableTaskClient` (https://github.com/microsoft/durabletask-dotnet/pull/264) - -## v1.2.0 - -- Adds support to recursively terminate/purge sub-orchestrations in `GrpcDurableTaskClient` (https://github.com/microsoft/durabletask-dotnet/pull/262) - -## v1.1.1 - -- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.1` - -## v1.1.0 - -- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.0` - -## v1.1.0-preview.2 - -- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.0-preview.2` - -## v1.1.0-preview.1 - -Adds support for durable entities. Learn more [here](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-entities?tabs=csharp). - -### Microsoft.DurableTask.Abstractions - -- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.0-preview.1` - -## v1.0.5 - -### Microsoft.DurableTask.Abstractions - -- Microsoft.Azure.DurableTask.Core dependency increased to `2.15.0` (https://github.com/microsoft/durabletask-dotnet/pull/212) - -### Microsoft.DurableTask.Worker - -- Fix re-encoding of events when using `TaskOrchestrationContext.ContinueAsNew(preserveUnprocessedEvents: true)` (https://github.com/microsoft/durabletask-dotnet/pull/212) - -## v1.0.4 - -### Microsoft.DurableTask.Worker - -- Fix handling of concurrent external events with the same name (https://github.com/microsoft/durabletask-dotnet/pull/194) - -## v1.0.3 - -### Microsoft.DurableTask.Worker - -- Fix instance ID not being passed in when using retry policy (https://github.com/microsoft/durabletask-dotnet/issues/174) - -### Microsoft.DurableTask.Worker.Grpc - -- Add `GrpcDurableTaskWorkerOptions.CallInvoker` as an alternative to `GrpcDurableTaskWorkerOptions.Channel` - -### Microsoft.DurableTask.Client.Grpc - -- Add `GrpcDurableTaskClientOptions.CallInvoker` as an alternative to `GrpcDurableTaskClientOptions.Channel` - -## v1.0.2 - -### Microsoft.DurableTask.Worker - -- Fix issue with `TaskOrchestrationContext.Parent` not being set. - -## v1.0.1 - -### Microsoft.DurableTask.Client - -- Fix incorrect bounds check on `PurgeResult` -- Address typo for `DurableTaskClient.GetInstancesAsync` (incorrectly pluralized) - - Added `GetInstanceAsync` - - Hide `GetInstancesAsync` from editor - -## v1.0.0 - -- Added `SuspendInstanceAsync` and `ResumeInstanceAsync` to `DurableTaskClient`. -- Rename `DurableTaskClient` methods - - `TerminateAsync` -> `TerminateInstanceAsync` - - `PurgeInstanceMetadataAsync` -> `PurgeInstanceAsync` - - `PurgeInstances` -> `PurgeAllInstancesAsync` - - `GetInstanceMetadataAsync` -> `GetInstanceAsync` - - `GetInstances` -> `GetAllInstancesAsync` -- `TaskOrchestrationContext.CreateReplaySafeLogger` now creates `ILogger` directly (as opposed to wrapping an existing `ILogger`). -- Durable Functions class-based syntax now resolves `ITaskActivity` instances from `IServiceProvider`, if available there. -- `DurableTaskClient` methods have been touched up to ensure `CancellationToken` is included, as well as is the last parameter. -- Removed obsolete/unimplemented local lambda activity calls from `TaskOrchestrationContext` -- Input is now an optional parameter on `TaskOrchestrationContext.ContinueAsNew` -- Multi-target gRPC projects to now use `Grpc.Net.Client` when appropriate (.NET6.0 and up) - -*Note: `Microsoft.DurableTask.Generators` is remaining as `preview.1`.* - -## v1.0.0-rc.1 - -### Included Packages - -Microsoft.DurableTask.Abstractions \ -Microsoft.DurableTask.Client \ -Microsoft.DurableTask.Client.Grpc \ -Microsoft.DurableTask.Worker \ -Microsoft.DurableTask.Worker.Grpc \ - -_see v1.0.0-preview.1 for `Microsoft.DurableTask.Generators`_ - -### Updates - -- Refactors and splits assemblies. - - `Microsoft.DurableTask.Abstractions` - - `Microsoft.DurableTask.Generators` - - `Microsoft.DurableTask.Client` - - `Microsoft.DurableTask.Client.Grpc` - - `Microsoft.DurableTask.Worker` - - `Microsoft.DurableTask.Worker.Grpc` -- Added more API documentation -- Adds ability to perform multi-instance query -- Adds `PurgeInstancesMetadataAsync` and `PurgeInstancesAsync` support and implementation to `GrpcDurableTaskClient` -- Fix issue with mixed Newtonsoft.Json and System.Text.Json serialization. -- `IDurableTaskClientProvider` added to support multiple named clients. -- Added new options pattern for starting new and sub orchestrations. -- Overhauled builder API built on top of .NET generic host. - - Now relies on dependency injection. - - Integrates into options pattern, giving a variety of ways for user configuration. - - Builder is now re-enterable. Multiple calls to `.AddDurableTask{Worker|Client}` with the same name will yield the exact same builder instance. - -### Breaking Changes - -- `Microsoft.DurableTask.Generators` reference removed. -- Added new abstract property `TaskOrchestrationContext.ParentInstance`. -- Added new abstract method `DurableTaskClient.PurgeInstancesAsync`. -- Renamed `TaskOrchestratorBase` to `TaskOrchestrator` - - `OnRunAsync` -> `RunAsync`, forced-nullability removed. - - Nullability can be done in generic params, ie: `MyOrchestrator : TaskOrchestrator` - - Nullability is not verified at runtime by the base class, it is up to the individual orchestrator implementations to verify their own nullability. -- Renamed `TaskActivityBase` to `TaskActivity` - - `OnRun` removed. With both `OnRun` and `OnRunAsync`, there was no compiler error when you did not implement one. The remaining method is now marked `abstract` to force an implementation. Synchronous implementation can still be done via `Task.FromResult`. - - `OnRunAsync` -> `RunAsync`, forced-nullability removed. - - Nullability can be done in generic params, ie: `MyActivity : TaskActivity` - - Nullability is not verified at runtime by the base class, it is up to the individual activity implementations to verify their own nullability. -- `TaskOrchestrationContext.StartSubOrchestrationAsync` refactored: - - `instanceId` parameter removed. Can now specify it via supplying `SubOrchestrationOptions` for `TaskOptions`. -- `TaskOptions` refactored to be a record type. - - Builder removed. - - Retry provided via a property `TaskRetryOptions`, which is a pseudo union-type which can be either a `RetryPolicy` or `AsyncRetryHandler`. - - `SubOrchestrationOptions` is a derived type that can be used to specific a sub-orchestrations instanceId. - - Helper method `.WithInstanceId(string? instanceId)` added. -- `DurableTaskClient.ScheduleNewOrchestrationInstanceAsync` refactored: - - `instanceId` and `startAfter` wrapped into `StartOrchestrationOptions` object. -- Builder API completely overhauled. Now built entirely on top of the .NET generic host. - - See samples for how the new API works. - - Supports multiple workers and named-clients. -- Ability to set `TaskName.Version` removed for now. Will be added when we address versioning. -- `IDurableTaskRegistry` removed, only `DurableTaskRegistry` concrete type. - - All lambda-methods renamed to `AddActivityFunc` and `AddOrchestratorFunc`. This was to avoid ambiguous or incorrect overload resolution with the factory methods. - -## v1.0.0-preview.1 - -### Included Packages - -Microsoft.DurableTask.Generators - -### Breaking Changes - -- `Microsoft.DurableTask.Generators` is now an optional package. - - no longer automatically brought in when referencing other packages. - - To get code generation, add `` to your csproj. -- `GeneratedDurableTaskExtensions.AddAllGeneratedTasks` made `internal` (from `public`) - - This is also to avoid conflicts when multiple files have this method generated. When wanting to expose to external consumes, a new extension method can be manually authored in the desired namespace and with an appropriate name which calls this method. - -## v0.4.1-beta - -Initial public release - - - - - +# Changelog + +## Unreleased + +- Updated private preview on-demand sandbox worker profile declarations to use `OnDemandSandboxOptions.AddActivity(...)`, and updated the on-demand sandbox sample to share activity name constants between the main app and remote worker. + +## v1.24.2 +- Bump DI.Abstractions and Bcl.AsyncInterfaces to 9.0.1 ([#3433](https://github.com/microsoft/durabletask-dotnet/pull/3433)) (#723) +- Validate UseWorkItemFilters names against registered tasks at worker build time ([#719](https://github.com/microsoft/durabletask-dotnet/pull/719)) +- Bump `Microsoft.Extensions.DependencyInjection.Abstractions` from 8.0.2 to 9.0.1 (and `Microsoft.Bcl.AsyncInterfaces` from 8.0.0 to 9.0.1, which the former transitively floors at 9.0.1) to align with the floor declared by `Microsoft.Azure.WebJobs 3.0.45 -> Microsoft.Extensions.Logging.Abstractions 9.0.1`. Fixes NU1605 in downstream Azure Functions Worker isolated apps consuming `Microsoft.DurableTask.Extensions.AzureBlobPayloads` ([Azure/azure-functions-durable-extension#3433](https://github.com/Azure/azure-functions-durable-extension/issues/3433)). +- Validate explicit `UseWorkItemFilters(filters)` filter names against the worker's `DurableTaskRegistry`. Filters that reference an orchestration, activity, or entity name not registered with the worker now throw `OptionsValidationException` at worker startup instead of silently waiting for work items that will never arrive. No customer-side validation call is required. ([#719](https://github.com/microsoft/durabletask-dotnet/pull/719)) + +## 1.24.1 +- Add retry to grpc calls that failed due to transient errors by @sophiatev ([#714](https://github.com/microsoft/durabletask-dotnet/pull/714)) + +## v1.24.0 +- Harden grpc worker and client against silent disconnects by @berndverst ([#708](https://github.com/microsoft/durabletask-dotnet/pull/708)) +- Preserve late events after continue-as-new by @berndverst ([#711](https://github.com/microsoft/durabletask-dotnet/pull/711)) +- Fix inprocesstesthost continueasnew stuck-instance race condition by @bachuv ([#707](https://github.com/microsoft/durabletask-dotnet/pull/707)) +- Fix continue-as-new race condition at inprocesstesthost by @nytian ([#703](https://github.com/microsoft/durabletask-dotnet/pull/703)) +- Add opt-in timeout to purgeinstancesfilter for partial purge by @yunchuwang ([#680](https://github.com/microsoft/durabletask-dotnet/pull/680)) + +## v1.23.3 +- fix: revert shared framework packages to 8.x for net8 Functions host compatibility ([#698](https://github.com/microsoft/durabletask-dotnet/pull/698)) +- Release v1.23.2 ([#693](https://github.com/microsoft/durabletask-dotnet/pull/693)) + +## v1.23.2 +- fix: improve large payload error handling — better error message and prevent infinite retry and fix conflict with auto chunking ([#691](https://github.com/microsoft/durabletask-dotnet/pull/691)) +- Bump dotnet-sdk from 10.0.103 to 10.0.201 ([#673](https://github.com/microsoft/durabletask-dotnet/pull/673)) +- Bump Microsoft.Azure.DurableTask.Core from 3.7.0 to 3.7.1 ([#685](https://github.com/microsoft/durabletask-dotnet/pull/685)) +- feat(copilot): add evidence-based Copilot customizations ([#690](https://github.com/microsoft/durabletask-dotnet/pull/690)) + +## v1.23.1 +- Fix CHANGELOG line ending preservation in Prepare Release workflow ([#687](https://github.com/microsoft/durabletask-dotnet/pull/687)) +- Add Prepare Release GitHub Action for automated release kickoff ([#686](https://github.com/microsoft/durabletask-dotnet/pull/686)) +- Add ContinueAsNewOptions with NewVersion support ([#682](https://github.com/microsoft/durabletask-dotnet/pull/682)) +- Fix concurrent timer race condition in InMemoryOrchestrationService ([#678](https://github.com/microsoft/durabletask-dotnet/pull/678)) + +## v1.23.0 +- Generate extension methods in task namespace instead of Microsoft.DurableTask ([#538](https://github.com/microsoft/durabletask-dotnet/pull/538)) +- Fix #668: Change work item filters from auto opt-in to explicit opt-in ([#669](https://github.com/microsoft/durabletask-dotnet/pull/669)) +- Add `ReplaySafeLoggerFactory` for context wrappers ([#670](https://github.com/microsoft/durabletask-dotnet/pull/670)) +- Add NuGet publish job for Microsoft.DurableTask.Analyzers ([#662](https://github.com/microsoft/durabletask-dotnet/pull/662)) +- Bump Azure.Identity from 1.17.1 to 1.18.0 ([#656](https://github.com/microsoft/durabletask-dotnet/pull/656)) +- Bump Microsoft.Azure.Functions.Worker.Extensions.DurableTask from 1.12.1 to 1.15.0 ([#658](https://github.com/microsoft/durabletask-dotnet/pull/658)) +- Add missing input validation to SuspendInstanceAsync and ResumeInstanceAsync ([#652](https://github.com/microsoft/durabletask-dotnet/pull/652)) +- Add ExportHistory package to NuGet publish pipeline ([#651](https://github.com/microsoft/durabletask-dotnet/pull/651)) +- Add OpenTelemetry sample and update deps ([#637](https://github.com/microsoft/durabletask-dotnet/pull/637)) +- Fix build warnings and clean up exception message ([#647](https://github.com/microsoft/durabletask-dotnet/pull/647)) + +## v1.22.0 +- Changing the default dedupe statuses behavior by sophiatev ([#622](https://github.com/microsoft/durabletask-dotnet/pull/622)) +- Bump Analyzers package version to 1.22.0 stable release (from 0.3.0) +- Add DURABLE0011: ContinueAsNew warning for unbounded orchestration loops ([#660](https://github.com/microsoft/durabletask-dotnet/pull/660)) + +## 1.21.0 +- Introduce WorkItemFilters into worker flow by halspang ([#616](https://github.com/microsoft/durabletask-dotnet/pull/616)) +- Fix Analyzers treating passed in variable argument name as null by wangbill ([#640](https://github.com/microsoft/durabletask-dotnet/pull/640)) +- Move DURABLE0009/0010 from Unshipped to Shipped for v0.3.0 by cgillum ([#641](https://github.com/microsoft/durabletask-dotnet/pull/641)) + +## 1.20.1 +- Fix GrpcChannel handle leak in AzureManaged backendby nytian ([#629](https://github.com/microsoft/durabletask-dotnet/pull/629)) + +## 1.20.0 +- Partial orchestration workitem completion support (merge after next dts dp release) by wangbill ([#514](https://github.com/microsoft/durabletask-dotnet/pull/514)) +- Export history job by wangbill ([#494](https://github.com/microsoft/durabletask-dotnet/pull/494)) +- Add dependency injection support to durabletasktesthost by Naiyuan Tian ([#613](https://github.com/microsoft/durabletask-dotnet/pull/613)) + +## v1.19.1 +- Throw an `InvalidOperationException` for purge requests on running orchestrations by sophiatev ([#611](https://github.com/microsoft/durabletask-dotnet/pull/611)) +- Validate c# identifiers in durabletask source generator by Copilot ([#578](https://github.com/microsoft/durabletask-dotnet/pull/578)) +- Document orchestration discovery and method probing behavior in analyzers by Copilot ([#594](https://github.com/microsoft/durabletask-dotnet/pull/594)) + +## v1.19.0 +- Extended sessions for entities in .net isolated by sophiatev ([#507](https://github.com/microsoft/durabletask-dotnet/pull/507)) +- Adding the ability to specify tags and a retry policy for suborchestrations by sophiatev ([#603](https://github.com/microsoft/durabletask-dotnet/pull/603)) +- Improve durabletask source generator detection and add optional project type configuration by Copilot ([#575](https://github.com/microsoft/durabletask-dotnet/pull/575)) +- Add timeprovider support to orchestration analyzer by Copilot ([#573](https://github.com/microsoft/durabletask-dotnet/pull/573)) +- Expand azure functions smoke tests to cover source generator scenarios by Copilot ([#604](https://github.com/microsoft/durabletask-dotnet/pull/604)) +- Fix "syntaxtree is not part of the compilation" exception in orchestration analyzers by Copilot ([#588](https://github.com/microsoft/durabletask-dotnet/pull/588)) +- Add waitforexternalevent overload with timeout and cancellation token by Copilot ([#555](https://github.com/microsoft/durabletask-dotnet/pull/555)) +- Fix source generator for void-returning activity functions by Copilot ([#554](https://github.com/microsoft/durabletask-dotnet/pull/554)) + +## v1.18.2 +- Add copy constructors to TaskOptions and sub-classes by halspang ([#587](https://github.com/microsoft/durabletask-dotnet/pull/587)) +- Change FunctionNotFound analyzer severity to Info for cross-assembly scenarios by Copilot ([#584](https://github.com/microsoft/durabletask-dotnet/pull/584)) +- Add Roslyn analyzer for non-contextual logger usage in orchestrations (DURABLE0010) by Copilot ([#553](https://github.com/microsoft/durabletask-dotnet/pull/553)) +- Add specific logging categories for Worker.Grpc and orchestration logs with backward-compatible opt-in by Copilot ([#583](https://github.com/microsoft/durabletask-dotnet/pull/583)) +- Fix flaky integration test race condition in dedup status check by Copilot ([#579](https://github.com/microsoft/durabletask-dotnet/pull/579)) +- Add analyzer to suggest input parameter binding over GetInput() by Copilot ([#550](https://github.com/microsoft/durabletask-dotnet/pull/550)) +- Add strongly-typed external events with DurableEventAttribute by Copilot ([#549](https://github.com/microsoft/durabletask-dotnet/pull/549)) +- Fix orchestration analyzer to detect non-function orchestrations correctly by Copilot ([#572](https://github.com/microsoft/durabletask-dotnet/pull/572)) +- Fix race condition in WaitForInstanceAsync causing intermittent test failures by Copilot ([#574](https://github.com/microsoft/durabletask-dotnet/pull/574)) +- Add HelpLinkUri to Roslyn analyzer diagnostics by Copilot ([#548](https://github.com/microsoft/durabletask-dotnet/pull/548)) +- Add DateTimeOffset.Now and DateTimeOffset.UtcNow detection to Roslyn analyzer by Copilot ([#547](https://github.com/microsoft/durabletask-dotnet/pull/547)) +- Bump Google.Protobuf from 3.33.1 to 3.33.2 by dependabot[bot] ([#569](https://github.com/microsoft/durabletask-dotnet/pull/569)) +- Add integration test coverage for Suspend/Resume operations by Copilot ([#546](https://github.com/microsoft/durabletask-dotnet/pull/546)) +- Bump coverlet.collector from 6.0.2 to 6.0.4 by dependabot[bot] ([#527](https://github.com/microsoft/durabletask-dotnet/pull/527)) +- Bump FluentAssertions from 6.12.1 to 6.12.2 by dependabot[bot] ([#528](https://github.com/microsoft/durabletask-dotnet/pull/528)) +- Add Azure Functions smoke tests with Docker CI automation by Copilot ([#545](https://github.com/microsoft/durabletask-dotnet/pull/545)) +- Bump dotnet-sdk from 10.0.100 to 10.0.101 by dependabot[bot] ([#568](https://github.com/microsoft/durabletask-dotnet/pull/568)) +- Add scheduled auto-closure for stale "Needs Author Feedback" issues by Copilot ([#566](https://github.com/microsoft/durabletask-dotnet/pull/566)) + +## v1.18.1 +- Support dedup status when starting orchestration by wangbill ([#542](https://github.com/microsoft/durabletask-dotnet/pull/542)) +- Add 404 exception handling in blobpayloadstore.downloadasync by Copilot ([#534](https://github.com/microsoft/durabletask-dotnet/pull/534)) +- Bump analyzers version to 0.2.0 by Copilot ([#552](https://github.com/microsoft/durabletask-dotnet/pull/552)) +- Add integration tests for exception type handling by Copilot ([#544](https://github.com/microsoft/durabletask-dotnet/pull/544)) +- Add roslyn analyzer to detect calls to non-existent functions (name mismatch) by Copilot ([#530](https://github.com/microsoft/durabletask-dotnet/pull/530)) +- Remove preview suffix by Copilot ([#541](https://github.com/microsoft/durabletask-dotnet/pull/541)) +- Add xml documentation with see cref links to generated code for better ide navigation by Copilot ([#535](https://github.com/microsoft/durabletask-dotnet/pull/535)) +- Add entity source generation support for durable functions by Copilot ([#533](https://github.com/microsoft/durabletask-dotnet/pull/533)) + +## v1.18.0 +- Add taskentity support to durabletasksourcegenerator by Copilot ([#517](https://github.com/microsoft/durabletask-dotnet/pull/517)) +- Bump azure.identity by dependabot[bot] ([#525](https://github.com/microsoft/durabletask-dotnet/pull/525)) +- Bump google.protobuf by dependabot[bot] ([#529](https://github.com/microsoft/durabletask-dotnet/pull/529)) +- Configure dependabot for dotnet-sdk updates by Tomer Rosenthal ([#524](https://github.com/microsoft/durabletask-dotnet/pull/524)) +- Add code review guidelines to copilot-instructions.md by Copilot ([#522](https://github.com/microsoft/durabletask-dotnet/pull/522)) +- Remove webapi sample by sophiatev ([#520](https://github.com/microsoft/durabletask-dotnet/pull/520)) +- Fix functioncontext check and polymorphic type conversions in activity analyzer by Naiyuan Tian ([#506](https://github.com/microsoft/durabletask-dotnet/pull/506)) +- Align waitforexternalevent waiter picking order to lifo by wangbill ([#509](https://github.com/microsoft/durabletask-dotnet/pull/509)) +- Update project to support .net 6.0 alongside .net 8.0 and .net 10 by Tomer Rosenthal ([#512](https://github.com/microsoft/durabletask-dotnet/pull/512)) +- Update project to target .net 8.0 and .net 10 and upgrade dependencies by Tomer Rosenthal ([#510](https://github.com/microsoft/durabletask-dotnet/pull/510)) +- Support worker features announcement by wangbill ([#502](https://github.com/microsoft/durabletask-dotnet/pull/502)) +- Introduce custom copilot review instructions by halspang ([#503](https://github.com/microsoft/durabletask-dotnet/pull/503)) +- Add API to get orchestration history ([#516](https://github.com/microsoft/durabletask-dotnet/pull/516)) + +## v1.17.1 +- Fix Worker Registry and Add Documentation Notes by nytian in [#462](https://github.com/microsoft/durabletask-dotnet/pull/495) +- Initial attempt to fix carryover events issue on continue-as-new by cgillum in [#496](https://github.com/microsoft/durabletask-dotnet/pull/496) +- Fix encoding of entity unlock events by sebastianburckhardt in [#462](https://github.com/microsoft/durabletask-dotnet/pull/462) + +## v1.17.0 +-Add Microsoft.DurableTask.Extensions.AzureBlobPayloads to nuget publish by YunchuWang in [#488](https://github.com/microsoft/durabletask-dotnet/pull/488) +-Add API for In-process Testing and Add Class-Syntax Integration Tests by nytian in [#476](https://github.com/microsoft/durabletask-dotnet/pull/476) +-Fix Purge Instance Comments by sophiatev in [#489](https://github.com/microsoft/durabletask-dotnet/pull/489) +-Fix ServiceCollectionExtensions.AddDurableTaskClient by sophiatev in [#490](https://github.com/microsoft/durabletask-dotnet/pull/490) +-Update zuremanaged sdks to official version by YunchuWang in [#493](https://github.com/microsoft/durabletask-dotnet/pull/493) +-Add Rewind to .NET isolated by sophiatev in [#479](https://github.com/microsoft/durabletask-dotnet/pull/479) +-Add tags field to CompleteOrchestratorAction by sophiatev in [#492](https://github.com/microsoft/durabletask-dotnet/pull/492) + +## v1.16.2 +- Generate changelog script + update changelog for v1.16.1 by wangbill ([#486](https://github.com/microsoft/durabletask-dotnet/pull/486)) +- Remove unnecessary project reference to grpc.azuremanagedbackend in azureblobpayloads.csproj by wangbill ([#485](https://github.com/microsoft/durabletask-dotnet/pull/485)) +- Large payload azure blob externalization support by wangbill ([#468](https://github.com/microsoft/durabletask-dotnet/pull/468)) + +## v1.16.1 +- Include exception properties in failure details when orchestration throws directly by Naiyuan Tian ([#482](https://github.com/microsoft/durabletask-dotnet/pull/482)) +- Set low priority for scheduled runs by Daniel Castro ([#477](https://github.com/microsoft/durabletask-dotnet/pull/477)) + +## v1.16.0 +- Include Exception Properties at FailureDetails by nytian in([#474](https://github.com/microsoft/durabletask-dotnet/pull/474)) + +## v1.15.1 +- Add version check to activities by @halspang in ([#472](https://github.com/microsoft/durabletask-dotnet/pull/472)) + +## v1.15.0 +- Abandon workitem if processing workitem failed by @YunchuWang in ([#467](https://github.com/microsoft/durabletask-dotnet/pull/467)) +- Extended Sessions for Isolated (Orchestrations) by @sophiatev in ([#449](https://github.com/microsoft/durabletask-dotnet/pull/449)) + +## v1.14.0 +- Add RestartAsync API Support at DurableTaskClient ([#456](https://github.com/microsoft/durabletask-dotnet/pull/456)) + +## v1.13.0 +- Add orchestration execution tracing ([#441](https://github.com/microsoft/durabletask-dotnet/pull/441)) + +## v1.12.0 + +- Activity tag support ([#426](https://github.com/microsoft/durabletask-dotnet/pull/426)) +- Adding Analyzer to build and release ([#444](https://github.com/microsoft/durabletask-dotnet/pull/444)) +- Add ability to filter orchestrations at worker ([#443](https://github.com/microsoft/durabletask-dotnet/pull/443)) +- Removing breaking change for TaskOptions ([#446](https://github.com/microsoft/durabletask-dotnet/pull/446)) +- Expose gRPC retry options in Azure Managed extensions ([#447](https://github.com/microsoft/durabletask-dotnet/pull/447)) + +## v1.11.0 + +- Add New Property Properties to TaskOrchestrationContext ([#415](https://github.com/microsoft/durabletask-dotnet/pull/415)) +- Add automatic retry on gateway timeout in `GrpcDurableTaskClient.WaitForInstanceCompletionAsync` ([#412](https://github.com/microsoft/durabletask-dotnet/pull/412)) +- Add specific logging for NotFound error on worker connection ([#413](https://github.com/microsoft/durabletask-dotnet/pull/413)) +- Add user agent header to gRPC called ([#417](https://github.com/microsoft/durabletask-dotnet/pull/417)) +- Enrich User-Agent Header in gRPC Metadata to indicate Client or Worker as caller ([#421](https://github.com/microsoft/durabletask-dotnet/pull/421)) +- Change DTS user agent metadata to avoid overlap with gRPC user agent ([#423](https://github.com/microsoft/durabletask-dotnet/pull/423)) +- Add extension methods for registering entities by type ([#427](https://github.com/microsoft/durabletask-dotnet/pull/427)) +- Add TaskVersion and utilize it for version overrides when starting orchestrations ([#416](https://github.com/microsoft/durabletask-dotnet/pull/416)) +- Update sub-orchestration default versioning ([#437](https://github.com/microsoft/durabletask-dotnet/pull/437)) +- Distributed Tracing for Entities (Isolated) ([#404](https://github.com/microsoft/durabletask-dotnet/pull/404)) + +## v1.10.0 + +- Update DurableTask.Core to v3.1.0 and Bump version to v1.10.0 by @nytian in ([#411](https://github.com/microsoft/durabletask-dotnet/pull/411)) + +## v1.9.1 + +- Add basic orchestration and activity execution logs by @cgillum in ([#405](https://github.com/microsoft/durabletask-dotnet/pull/405)) +- Add default version in `TaskOrchestrationContext` by @halspang in ([#408](https://github.com/microsoft/durabletask-dotnet/pull/408)) + +## v1.9.0 + +- Fix schedule sample logging setup by @YunchuWang in ([#392](https://github.com/microsoft/durabletask-dotnet/pull/392)) +- Introduce versioning to the DurableTaskClient by @halspang in ([#393](https://github.com/microsoft/durabletask-dotnet/pull/393)) +- Support for local credential types for DTS by @cgillum in ([#396](https://github.com/microsoft/durabletask-dotnet/pull/396)) +- Add utilities for easier versioning usage by @halspang in ([#394](https://github.com/microsoft/durabletask-dotnet/pull/394)) +- Add tags to CreateInstanceRequest by @torosent in ([#397](https://github.com/microsoft/durabletask-dotnet/pull/397)) +- Partial Purge Support by @YunchuWang in ([#400](https://github.com/microsoft/durabletask-dotnet/pull/400)) +- Dts Grpc client retry support by @YunchuWang in ([#403](https://github.com/microsoft/durabletask-dotnet/pull/403)) +- Introduce orchestration versioning into worker by @halspang in ([#401](https://github.com/microsoft/durabletask-dotnet/pull/401)) + +## v1.8.1 + +- Add timeout to gRPC workitem streaming ([#390](https://github.com/microsoft/durabletask-dotnet/pull/390)) + +## v1.8.0 + +- Add Schedule Support for Durable Task Framework ([#368](https://github.com/microsoft/durabletask-dotnet/pull/368)) +- Fixes and improvements + +## v1.7.0 + +- Add parent trace context information when scheduling an orchestration ([#358](https://github.com/microsoft/durabletask-dotnet/pull/358)) + +## v1.6.0 + +- Added new preview packages, `Microsoft.DurableTask.Client.AzureManaged` and `Microsoft.DurableTask.Worker.AzureManaged` +- Move to Central Package Management ([#373](https://github.com/microsoft/durabletask-dotnet/pull/373)) + +### Microsoft.DurableTask.Client + +- Add new `IDurableTaskClientBuilder AddDurableTaskClient(IServiceCollection, string?)` API + +### Microsoft.DurableTask.Worker + +- Add new `IDurableTaskWorkerBuilder AddDurableTaskWorker(IServiceCollection, string?)` API +- Add support for work item history streaming + +### Microsoft.DurableTask.Worker.Grpc + +- Provide entity support for direct grpc connections to DTS ([#369](https://github.com/microsoft/durabletask-dotnet/pull/369)) + +### Microsoft.DurableTask.Grpc + +- Replace submodule for proto files with download script for easier maintenance +- Update to latest proto files + +## v1.5.0 + +- Implement work item completion tokens for standalone worker scenarios ([#359](https://github.com/microsoft/durabletask-dotnet/pull/359)) +- Support for worker concurrency configuration ([#359](https://github.com/microsoft/durabletask-dotnet/pull/359)) +- Bump System.Text.Json to 6.0.10 +- Initial support for the Azure-managed [Durable Task Scheduler](https://techcommunity.microsoft.com/blog/appsonazureblog/announcing-limited-early-access-of-the-durable-task-scheduler-for-azure-durable-/4286526) preview. + +## v1.4.0 + +- Microsoft.Azure.DurableTask.Core dependency increased to `3.0.0` + +## v1.3.0 + +### Microsoft.DurableTask.Abstractions + +- Add `RetryPolicy.Handle` property to allow for exception filtering on retries ([#314](https://github.com/microsoft/durabletask-dotnet/pull/314)) + +## v1.2.4 + +- Microsoft.Azure.DurableTask.Core dependency increased to `2.17.1` + +## v1.2.3 + +### Microsoft.DurableTask.Client + +- Fix filter not being passed along in `PurgeAllInstancesAsync` (https://github.com/microsoft/durabletask-dotnet/pull/289) + +### Microsoft.DurableTask.Abstractions + +- Enable inner exception detail propagation in `TaskFailureDetails` ([#290](https://github.com/microsoft/durabletask-dotnet/pull/290)) +- Microsoft.Azure.DurableTask.Core dependency increased to `2.17.0` + +## v1.2.2 + +### Microsoft.DurableTask.Abstractions + +- Fix `TaskFailureDetails.IsCausedBy` to support custom exceptions and 3rd party exceptions ([#273](https://github.com/microsoft/durabletask-dotnet/pull/273)) +- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.2` + +### Microsoft.DurableTask.Client + +- Fix typo in `PurgeInstanceAsync` in `DurableTaskClient` (https://github.com/microsoft/durabletask-dotnet/pull/264) + +## v1.2.0 + +- Adds support to recursively terminate/purge sub-orchestrations in `GrpcDurableTaskClient` (https://github.com/microsoft/durabletask-dotnet/pull/262) + +## v1.1.1 + +- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.1` + +## v1.1.0 + +- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.0` + +## v1.1.0-preview.2 + +- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.0-preview.2` + +## v1.1.0-preview.1 + +Adds support for durable entities. Learn more [here](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-entities?tabs=csharp). + +### Microsoft.DurableTask.Abstractions + +- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.0-preview.1` + +## v1.0.5 + +### Microsoft.DurableTask.Abstractions + +- Microsoft.Azure.DurableTask.Core dependency increased to `2.15.0` (https://github.com/microsoft/durabletask-dotnet/pull/212) + +### Microsoft.DurableTask.Worker + +- Fix re-encoding of events when using `TaskOrchestrationContext.ContinueAsNew(preserveUnprocessedEvents: true)` (https://github.com/microsoft/durabletask-dotnet/pull/212) + +## v1.0.4 + +### Microsoft.DurableTask.Worker + +- Fix handling of concurrent external events with the same name (https://github.com/microsoft/durabletask-dotnet/pull/194) + +## v1.0.3 + +### Microsoft.DurableTask.Worker + +- Fix instance ID not being passed in when using retry policy (https://github.com/microsoft/durabletask-dotnet/issues/174) + +### Microsoft.DurableTask.Worker.Grpc + +- Add `GrpcDurableTaskWorkerOptions.CallInvoker` as an alternative to `GrpcDurableTaskWorkerOptions.Channel` + +### Microsoft.DurableTask.Client.Grpc + +- Add `GrpcDurableTaskClientOptions.CallInvoker` as an alternative to `GrpcDurableTaskClientOptions.Channel` + +## v1.0.2 + +### Microsoft.DurableTask.Worker + +- Fix issue with `TaskOrchestrationContext.Parent` not being set. + +## v1.0.1 + +### Microsoft.DurableTask.Client + +- Fix incorrect bounds check on `PurgeResult` +- Address typo for `DurableTaskClient.GetInstancesAsync` (incorrectly pluralized) + - Added `GetInstanceAsync` + - Hide `GetInstancesAsync` from editor + +## v1.0.0 + +- Added `SuspendInstanceAsync` and `ResumeInstanceAsync` to `DurableTaskClient`. +- Rename `DurableTaskClient` methods + - `TerminateAsync` -> `TerminateInstanceAsync` + - `PurgeInstanceMetadataAsync` -> `PurgeInstanceAsync` + - `PurgeInstances` -> `PurgeAllInstancesAsync` + - `GetInstanceMetadataAsync` -> `GetInstanceAsync` + - `GetInstances` -> `GetAllInstancesAsync` +- `TaskOrchestrationContext.CreateReplaySafeLogger` now creates `ILogger` directly (as opposed to wrapping an existing `ILogger`). +- Durable Functions class-based syntax now resolves `ITaskActivity` instances from `IServiceProvider`, if available there. +- `DurableTaskClient` methods have been touched up to ensure `CancellationToken` is included, as well as is the last parameter. +- Removed obsolete/unimplemented local lambda activity calls from `TaskOrchestrationContext` +- Input is now an optional parameter on `TaskOrchestrationContext.ContinueAsNew` +- Multi-target gRPC projects to now use `Grpc.Net.Client` when appropriate (.NET6.0 and up) + +*Note: `Microsoft.DurableTask.Generators` is remaining as `preview.1`.* + +## v1.0.0-rc.1 + +### Included Packages + +Microsoft.DurableTask.Abstractions \ +Microsoft.DurableTask.Client \ +Microsoft.DurableTask.Client.Grpc \ +Microsoft.DurableTask.Worker \ +Microsoft.DurableTask.Worker.Grpc \ + +_see v1.0.0-preview.1 for `Microsoft.DurableTask.Generators`_ + +### Updates + +- Refactors and splits assemblies. + - `Microsoft.DurableTask.Abstractions` + - `Microsoft.DurableTask.Generators` + - `Microsoft.DurableTask.Client` + - `Microsoft.DurableTask.Client.Grpc` + - `Microsoft.DurableTask.Worker` + - `Microsoft.DurableTask.Worker.Grpc` +- Added more API documentation +- Adds ability to perform multi-instance query +- Adds `PurgeInstancesMetadataAsync` and `PurgeInstancesAsync` support and implementation to `GrpcDurableTaskClient` +- Fix issue with mixed Newtonsoft.Json and System.Text.Json serialization. +- `IDurableTaskClientProvider` added to support multiple named clients. +- Added new options pattern for starting new and sub orchestrations. +- Overhauled builder API built on top of .NET generic host. + - Now relies on dependency injection. + - Integrates into options pattern, giving a variety of ways for user configuration. + - Builder is now re-enterable. Multiple calls to `.AddDurableTask{Worker|Client}` with the same name will yield the exact same builder instance. + +### Breaking Changes + +- `Microsoft.DurableTask.Generators` reference removed. +- Added new abstract property `TaskOrchestrationContext.ParentInstance`. +- Added new abstract method `DurableTaskClient.PurgeInstancesAsync`. +- Renamed `TaskOrchestratorBase` to `TaskOrchestrator` + - `OnRunAsync` -> `RunAsync`, forced-nullability removed. + - Nullability can be done in generic params, ie: `MyOrchestrator : TaskOrchestrator` + - Nullability is not verified at runtime by the base class, it is up to the individual orchestrator implementations to verify their own nullability. +- Renamed `TaskActivityBase` to `TaskActivity` + - `OnRun` removed. With both `OnRun` and `OnRunAsync`, there was no compiler error when you did not implement one. The remaining method is now marked `abstract` to force an implementation. Synchronous implementation can still be done via `Task.FromResult`. + - `OnRunAsync` -> `RunAsync`, forced-nullability removed. + - Nullability can be done in generic params, ie: `MyActivity : TaskActivity` + - Nullability is not verified at runtime by the base class, it is up to the individual activity implementations to verify their own nullability. +- `TaskOrchestrationContext.StartSubOrchestrationAsync` refactored: + - `instanceId` parameter removed. Can now specify it via supplying `SubOrchestrationOptions` for `TaskOptions`. +- `TaskOptions` refactored to be a record type. + - Builder removed. + - Retry provided via a property `TaskRetryOptions`, which is a pseudo union-type which can be either a `RetryPolicy` or `AsyncRetryHandler`. + - `SubOrchestrationOptions` is a derived type that can be used to specific a sub-orchestrations instanceId. + - Helper method `.WithInstanceId(string? instanceId)` added. +- `DurableTaskClient.ScheduleNewOrchestrationInstanceAsync` refactored: + - `instanceId` and `startAfter` wrapped into `StartOrchestrationOptions` object. +- Builder API completely overhauled. Now built entirely on top of the .NET generic host. + - See samples for how the new API works. + - Supports multiple workers and named-clients. +- Ability to set `TaskName.Version` removed for now. Will be added when we address versioning. +- `IDurableTaskRegistry` removed, only `DurableTaskRegistry` concrete type. + - All lambda-methods renamed to `AddActivityFunc` and `AddOrchestratorFunc`. This was to avoid ambiguous or incorrect overload resolution with the factory methods. + +## v1.0.0-preview.1 + +### Included Packages + +Microsoft.DurableTask.Generators + +### Breaking Changes + +- `Microsoft.DurableTask.Generators` is now an optional package. + - no longer automatically brought in when referencing other packages. + - To get code generation, add `` to your csproj. +- `GeneratedDurableTaskExtensions.AddAllGeneratedTasks` made `internal` (from `public`) + - This is also to avoid conflicts when multiple files have this method generated. When wanting to expose to external consumes, a new extension method can be manually authored in the desired namespace and with an appropriate name which calls this method. + +## v0.4.1-beta + +Initial public release + + + + + diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index 051dd2c1..f239b75b 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -1,4 +1,3 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.3.32901.215 @@ -117,7 +116,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReplaySafeLoggerFactorySamp EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{21303FBF-2A2B-17C2-D2DF-3E924022E940}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureManagedServerless", "src\Extensions\AzureManagedServerless\AzureManagedServerless.csproj", "{C6DC28DC-95CE-42DA-B02C-FFB2BA1CB1A2}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureManagedOnDemandSandbox", "src\Extensions\AzureManagedOnDemandSandbox\AzureManagedOnDemandSandbox.csproj", "{C6DC28DC-95CE-42DA-B02C-FFB2BA1CB1A2}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{D4587EC0-1B16-8420-7502-A967139249D4}" EndProject @@ -125,13 +124,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManage EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{00205C88-F000-28F2-A910-C6FA00E065EE}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureManagedServerless.Tests", "test\Extensions\AzureManagedServerless.Tests\AzureManagedServerless.Tests.csproj", "{4D50F5B2-4782-486F-A9AA-073D798CC60D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureManagedOnDemandSandbox.Tests", "test\Extensions\AzureManagedOnDemandSandbox.Tests\AzureManagedOnDemandSandbox.Tests.csproj", "{4D50F5B2-4782-486F-A9AA-073D798CC60D}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "serverless", "serverless", "{5BD6F026-413E-9AC5-D159-8E8D9F26EF1B}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "on-demand-sandbox", "on-demand-sandbox", "{5BD6F026-413E-9AC5-D159-8E8D9F26EF1B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "main-app", "samples\serverless\main-app\main-app.csproj", "{4535F88F-EA1C-4C6F-84D5-93535EE1568C}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "main-app", "samples\on-demand-sandbox\main-app\main-app.csproj", "{4535F88F-EA1C-4C6F-84D5-93535EE1568C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "remote-worker", "samples\serverless\remote-worker\remote-worker.csproj", "{562E5DB9-761B-4DE9-98CB-C364F6DE558E}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "remote-worker", "samples\on-demand-sandbox\remote-worker\remote-worker.csproj", "{562E5DB9-761B-4DE9-98CB-C364F6DE558E}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/README.md b/README.md index 2094e890..8fe32272 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Durable Task .NET Client SDK +# Durable Task .NET Client SDK [![Build status](https://github.com/microsoft/durabletask-dotnet/workflows/Validate%20Build/badge.svg)](https://github.com/microsoft/durabletask-dotnet/actions?workflow=Validate+Build) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) @@ -165,7 +165,7 @@ The Durable Task Scheduler for Azure Functions is a managed backend that is curr This SDK can also be used with the Durable Task Scheduler directly, without any Durable Functions dependency. To get started, sign up for the [Durable Task Scheduler private preview](https://techcommunity.microsoft.com/blog/appsonazureblog/announcing-limited-early-access-of-the-durable-task-scheduler-for-azure-durable-/4286526) and follow the instructions to create a new Durable Task Scheduler instance. Once granted access to the private preview GitHub repository, you can find samples and documentation for getting started [here](https://github.com/Azure/Azure-Functions-Durable-Task-Scheduler-Private-Preview/tree/main/samples/portable-sdk/dotnet/AspNetWebApp#readme). -The [serverless activities sample](samples/serverless/README.md) shows how to declare selected activities for DTS-managed serverless execution and build the remote worker container image separately from the declarer app. +The [on-demand sandbox activities sample](samples/on-demand-sandbox/README.md) shows how to declare selected activities for DTS-managed on-demand sandbox execution and build the remote worker container image separately from the declarer app. ## Obtaining the Protobuf definitions diff --git a/samples/serverless/README.md b/samples/on-demand-sandbox/README.md similarity index 60% rename from samples/serverless/README.md rename to samples/on-demand-sandbox/README.md index 895cd902..bf6ae193 100644 --- a/samples/serverless/README.md +++ b/samples/on-demand-sandbox/README.md @@ -1,20 +1,20 @@ -# Serverless Activities Sample +# On-demand sandbox activities sample -This sample shows how to run selected Durable Task activities in DTS-managed serverless sandboxes. +This sample shows how to run selected Durable Task activities in DTS-managed on-demand sandboxes. The sample is intentionally split into two projects: | Path | Purpose | | --- | --- | | `shared/` | Defines activity name constants shared by the main app and remote worker. | -| `main-app/` | Runs locally or in a normal app host. It declares the serverless activity and starts one hello orchestration. | -| `remote-worker/` | Builds the container image that DTS starts inside a serverless sandbox. It contains the remote hello activity. | +| `main-app/` | Runs locally or in a normal app host. It declares the on-demand sandbox activity and starts one hello orchestration. | +| `remote-worker/` | Builds the container image that DTS starts inside a sandbox. It contains the remote hello activity. | ## Build ```powershell -dotnet build .\samples\serverless\main-app\main-app.csproj -dotnet build .\samples\serverless\remote-worker\remote-worker.csproj +dotnet build .\samples\on-demand-sandbox\main-app\main-app.csproj +dotnet build .\samples\on-demand-sandbox\remote-worker\remote-worker.csproj ``` ## Build the remote worker image @@ -22,8 +22,8 @@ dotnet build .\samples\serverless\remote-worker\remote-worker.csproj Run from the repository root: ```powershell -$image = ".azurecr.io/dts-serverless-sample:" -docker build -f .\samples\serverless\remote-worker\Containerfile -t $image . +$image = ".azurecr.io/dts-ondemand-sandbox-sample:" +docker build -f .\samples\on-demand-sandbox\remote-worker\Containerfile -t $image . docker push $image ``` @@ -32,7 +32,7 @@ docker push $image The main app uses `DefaultAzureCredential`; sign in with Azure CLI or configure another supported Azure identity before running it. After pushing the remote worker image, set `ContainerImage` in `main-app/WorkerProfiles.cs` to the pushed image reference. The worker profile -class declares the image, CPU, memory, max concurrency, and serverless activity +class declares the image, CPU, memory, max concurrency, and on-demand sandbox activity names with `options.AddActivity(...)`. The main app and remote worker both use the `shared/ActivityNames.cs` constants so the declaration and worker registration stay in sync. @@ -41,9 +41,9 @@ Update `main-app/appsettings.json` with your scheduler endpoint and task hub: ```json { - "ServerlessSample": { + "OnDemandSandboxSample": { "EndpointAddress": "https://", - "TaskHubName": "ServerlessPocHub" + "TaskHubName": "OnDemandSandboxPocHub" } } ``` @@ -51,19 +51,19 @@ Update `main-app/appsettings.json` with your scheduler endpoint and task hub: Then run the main app: ```powershell -Push-Location .\samples\serverless\main-app +Push-Location .\samples\on-demand-sandbox\main-app dotnet run Pop-Location ``` -Expected output includes the serverless activity result: +Expected output includes the on-demand sandbox activity result: ```text Runtime status: Completed -Output: "hello locally: serverless-sample; hello remotely from pid=: serverless-sample" +Output: "hello locally: on-demand-sandbox-sample; hello remotely from pid=: on-demand-sandbox-sample" ``` -Use the Durable Task Scheduler dashboard's Serverless Activities preview tab to inspect serverless activity runtimes and stream runtime logs. +Use the Durable Task Scheduler dashboard's On-demand sandbox preview tab to inspect sandboxes and stream runtime logs. The remote worker image does not need customer-provided DTS runtime settings. DTS injects the scheduler endpoint, task hub, worker profile, capacity, substrate, diff --git a/samples/serverless/main-app/Activities.cs b/samples/on-demand-sandbox/main-app/Activities.cs similarity index 72% rename from samples/serverless/main-app/Activities.cs rename to samples/on-demand-sandbox/main-app/Activities.cs index ef749ea6..7f6b23bb 100644 --- a/samples/serverless/main-app/Activities.cs +++ b/samples/on-demand-sandbox/main-app/Activities.cs @@ -1,15 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace Microsoft.DurableTask.Samples.Serverless.MainApp; +namespace Microsoft.DurableTask.Samples.OnDemandSandbox.MainApp; -internal static class ServerlessTaskNames +internal static class OnDemandSandboxTaskNames { public const string LocalHello = "LocalHello"; public const string HelloOrchestrator = nameof(HelloOrchestrator); } -[DurableTask(ServerlessTaskNames.LocalHello)] +[DurableTask(OnDemandSandboxTaskNames.LocalHello)] internal sealed class LocalHelloActivity : TaskActivity { public override Task RunAsync(TaskActivityContext context, string input) diff --git a/samples/serverless/main-app/Orchestrators.cs b/samples/on-demand-sandbox/main-app/Orchestrators.cs similarity index 75% rename from samples/serverless/main-app/Orchestrators.cs rename to samples/on-demand-sandbox/main-app/Orchestrators.cs index 3b71dea5..8bd6e8eb 100644 --- a/samples/serverless/main-app/Orchestrators.cs +++ b/samples/on-demand-sandbox/main-app/Orchestrators.cs @@ -2,16 +2,16 @@ // Licensed under the MIT License. using Microsoft.DurableTask; -using Microsoft.DurableTask.Samples.Serverless.Shared; +using Microsoft.DurableTask.Samples.OnDemandSandbox.Shared; -namespace Microsoft.DurableTask.Samples.Serverless.MainApp; +namespace Microsoft.DurableTask.Samples.OnDemandSandbox.MainApp; [DurableTask(nameof(HelloOrchestrator))] internal sealed class HelloOrchestrator : TaskOrchestrator { public override async Task RunAsync(TaskOrchestrationContext context, string input) { - string localResult = await context.CallActivityAsync(ServerlessTaskNames.LocalHello, input); + string localResult = await context.CallActivityAsync(OnDemandSandboxTaskNames.LocalHello, input); string remoteResult = await context.CallActivityAsync(ActivityNames.RemoteHello, input); return $"{localResult}; {remoteResult}"; } diff --git a/samples/on-demand-sandbox/main-app/Program.cs b/samples/on-demand-sandbox/main-app/Program.cs new file mode 100644 index 00000000..650a3f18 --- /dev/null +++ b/samples/on-demand-sandbox/main-app/Program.cs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Identity; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Samples.OnDemandSandbox.MainApp; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +const string Input = "on-demand-sandbox-sample"; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +string endpoint = builder.Configuration["OnDemandSandboxSample:EndpointAddress"]!; +string taskHub = builder.Configuration["OnDemandSandboxSample:TaskHubName"]!; +int orchestrationCount = GetOrchestrationCount(builder.Configuration); +TokenCredential credential = new DefaultAzureCredential(); +builder.Logging.AddSimpleConsole(options => +{ + options.SingleLine = true; + options.UseUtcTimestamp = true; + options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ "; +}); + +builder.Services.AddDurableTaskWorker(workerBuilder => +{ + workerBuilder.AddTasks(tasks => tasks.AddAllGeneratedTasks()); + workerBuilder.UseDurableTaskScheduler(options => + { + options.EndpointAddress = endpoint; + options.TaskHubName = taskHub; + options.Credential = credential; + }); + + workerBuilder.EnableSandboxActivities(); +}); + +builder.Services.AddDurableTaskClient(clientBuilder => +{ + clientBuilder.UseDurableTaskScheduler(options => + { + options.EndpointAddress = endpoint; + options.TaskHubName = taskHub; + options.Credential = credential; + }); +}); + +using IHost host = builder.Build(); + +await host.StartAsync(); + +DurableTaskClient client = host.Services.GetRequiredService(); +List instanceIds = new(orchestrationCount); +for (int index = 1; index <= orchestrationCount; index++) +{ + string input = orchestrationCount == 1 ? Input : $"{Input}-{index:D3}"; + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( + OnDemandSandboxTaskNames.HelloOrchestrator, + input: input); + instanceIds.Add(instanceId); + Console.WriteLine($"Started orchestration {index}/{orchestrationCount}: {instanceId}"); +} + +List> completionTasks = new(orchestrationCount); +foreach (string instanceId in instanceIds) +{ + completionTasks.Add(client.WaitForInstanceCompletionAsync( + instanceId, + getInputsAndOutputs: true)); +} + +OrchestrationMetadata[] results = await Task.WhenAll(completionTasks); +int completedCount = 0; +for (int index = 0; index < results.Length; index++) +{ + OrchestrationMetadata? result = results[index]; + if (result?.RuntimeStatus == OrchestrationRuntimeStatus.Completed) + { + completedCount++; + } + + Console.WriteLine($"Orchestration {index + 1}/{orchestrationCount}: {instanceIds[index]}"); + Console.WriteLine($"Runtime status: {result?.RuntimeStatus}"); + Console.WriteLine($"Output: {result?.SerializedOutput ?? ""}"); +} + +Console.WriteLine($"Completed orchestrations: {completedCount}/{orchestrationCount}"); + +await host.StopAsync(); + +static int GetOrchestrationCount(IConfiguration configuration) +{ + string? configuredValue = configuration["OnDemandSandboxSample:OrchestrationCount"]; + if (int.TryParse(configuredValue, out int configuredCount) && configuredCount > 0) + { + return configuredCount; + } + + return 1; +} diff --git a/samples/on-demand-sandbox/main-app/WorkerProfiles.cs b/samples/on-demand-sandbox/main-app/WorkerProfiles.cs new file mode 100644 index 00000000..03608f6c --- /dev/null +++ b/samples/on-demand-sandbox/main-app/WorkerProfiles.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; +using Microsoft.DurableTask.Samples.OnDemandSandbox.Shared; + +namespace Microsoft.DurableTask.Samples.OnDemandSandbox.MainApp; + +[OnDemandSandboxWorkerProfile("default")] +internal sealed class DefaultSandboxWorkerProfile : ISandboxWorkerProfile +{ + public void Configure(OnDemandSandboxOptions options) + { + options.ContainerImage = Environment.GetEnvironmentVariable("DTS_ON_DEMAND_SANDBOX_CONTAINER_IMAGE") ?? "on-demand-sandbox-remote-worker:local"; + options.Cpu = "1000m"; + options.Memory = "2048Mi"; + options.MaxConcurrentActivities = 1; + AddEnvironmentVariable(options, "SAMPLE_REMOTE_MARKER"); + AddEnvironmentVariable(options, "SAMPLE_REMOTE_DELAY_MS"); + options.AddActivity(ActivityNames.RemoteHello); + } + + static void AddEnvironmentVariable(OnDemandSandboxOptions options, string name) + { + string? value = Environment.GetEnvironmentVariable(name); + if (!string.IsNullOrWhiteSpace(value)) + { + options.EnvironmentVariables[name] = value; + } + } +} diff --git a/samples/on-demand-sandbox/main-app/appsettings.json b/samples/on-demand-sandbox/main-app/appsettings.json new file mode 100644 index 00000000..78415af8 --- /dev/null +++ b/samples/on-demand-sandbox/main-app/appsettings.json @@ -0,0 +1,6 @@ +{ + "OnDemandSandboxSample": { + "EndpointAddress": "https://", + "TaskHubName": "OnDemandSandboxPocHub" + } +} diff --git a/samples/serverless/main-app/main-app.csproj b/samples/on-demand-sandbox/main-app/main-app.csproj similarity index 82% rename from samples/serverless/main-app/main-app.csproj rename to samples/on-demand-sandbox/main-app/main-app.csproj index 94b110e6..70760f5d 100644 --- a/samples/serverless/main-app/main-app.csproj +++ b/samples/on-demand-sandbox/main-app/main-app.csproj @@ -4,8 +4,8 @@ Exe net10.0 enable - ServerlessMainApp - Microsoft.DurableTask.Samples.Serverless.MainApp + OnDemandSandboxMainApp + Microsoft.DurableTask.Samples.OnDemandSandbox.MainApp @@ -16,7 +16,7 @@ - + diff --git a/samples/serverless/remote-worker/Activities.cs b/samples/on-demand-sandbox/remote-worker/Activities.cs similarity index 77% rename from samples/serverless/remote-worker/Activities.cs rename to samples/on-demand-sandbox/remote-worker/Activities.cs index 6fb44313..b2439812 100644 --- a/samples/serverless/remote-worker/Activities.cs +++ b/samples/on-demand-sandbox/remote-worker/Activities.cs @@ -2,9 +2,9 @@ // Licensed under the MIT License. using Microsoft.DurableTask; -using Microsoft.DurableTask.Samples.Serverless.Shared; +using Microsoft.DurableTask.Samples.OnDemandSandbox.Shared; -namespace Microsoft.DurableTask.Samples.Serverless.RemoteWorker; +namespace Microsoft.DurableTask.Samples.OnDemandSandbox.RemoteWorker; [DurableTask(ActivityNames.RemoteHello)] internal sealed class RemoteHelloActivity : TaskActivity diff --git a/samples/serverless/remote-worker/Containerfile b/samples/on-demand-sandbox/remote-worker/Containerfile similarity index 87% rename from samples/serverless/remote-worker/Containerfile rename to samples/on-demand-sandbox/remote-worker/Containerfile index 18f24b23..6df4b6ae 100644 --- a/samples/serverless/remote-worker/Containerfile +++ b/samples/on-demand-sandbox/remote-worker/Containerfile @@ -6,7 +6,7 @@ ARG TARGETARCH COPY . /src/durabletask-dotnet -WORKDIR /src/durabletask-dotnet/samples/serverless/remote-worker +WORKDIR /src/durabletask-dotnet/samples/on-demand-sandbox/remote-worker RUN case "$TARGETARCH" in \ amd64) runtime_identifier=linux-x64 ;; \ arm64) runtime_identifier=linux-arm64 ;; \ @@ -31,4 +31,4 @@ EXPOSE 8080 COPY --from=build /app/publish ./ -ENTRYPOINT ["dotnet", "ServerlessRemoteWorker.dll"] +ENTRYPOINT ["dotnet", "OnDemandSandboxRemoteWorker.dll"] diff --git a/samples/serverless/remote-worker/Containerfile.dockerignore b/samples/on-demand-sandbox/remote-worker/Containerfile.dockerignore similarity index 81% rename from samples/serverless/remote-worker/Containerfile.dockerignore rename to samples/on-demand-sandbox/remote-worker/Containerfile.dockerignore index 9b8836cf..7def663b 100644 --- a/samples/serverless/remote-worker/Containerfile.dockerignore +++ b/samples/on-demand-sandbox/remote-worker/Containerfile.dockerignore @@ -12,8 +12,8 @@ !samples/ !samples/Directory.Build.props !samples/Directory.Packages.props -!samples/serverless/ -!samples/serverless/** +!samples/on-demand-sandbox/ +!samples/on-demand-sandbox/** **/bin/ **/obj/ **/.git/ diff --git a/samples/serverless/remote-worker/Program.cs b/samples/on-demand-sandbox/remote-worker/Program.cs similarity index 87% rename from samples/serverless/remote-worker/Program.cs rename to samples/on-demand-sandbox/remote-worker/Program.cs index b705fba5..ef12fa29 100644 --- a/samples/serverless/remote-worker/Program.cs +++ b/samples/on-demand-sandbox/remote-worker/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.DurableTask.Samples.Serverless.RemoteWorker; +using Microsoft.DurableTask.Samples.OnDemandSandbox.RemoteWorker; using Microsoft.DurableTask.Worker; using Microsoft.DurableTask.Worker.AzureManaged; using Microsoft.Extensions.DependencyInjection; @@ -22,7 +22,7 @@ { tasks.AddActivity(); }); - workerBuilder.UseServerlessWorker(); + workerBuilder.UseSandboxWorker(); }); await builder.Build().RunAsync(); diff --git a/samples/serverless/remote-worker/remote-worker.csproj b/samples/on-demand-sandbox/remote-worker/remote-worker.csproj similarity index 76% rename from samples/serverless/remote-worker/remote-worker.csproj rename to samples/on-demand-sandbox/remote-worker/remote-worker.csproj index 0446c05f..8ad4fad5 100644 --- a/samples/serverless/remote-worker/remote-worker.csproj +++ b/samples/on-demand-sandbox/remote-worker/remote-worker.csproj @@ -4,8 +4,8 @@ Exe net10.0 enable - ServerlessRemoteWorker - Microsoft.DurableTask.Samples.Serverless.RemoteWorker + OnDemandSandboxRemoteWorker + Microsoft.DurableTask.Samples.OnDemandSandbox.RemoteWorker @@ -14,7 +14,7 @@ - + diff --git a/samples/serverless/shared/ActivityNames.cs b/samples/on-demand-sandbox/shared/ActivityNames.cs similarity index 72% rename from samples/serverless/shared/ActivityNames.cs rename to samples/on-demand-sandbox/shared/ActivityNames.cs index e7b0f5ab..72619b95 100644 --- a/samples/serverless/shared/ActivityNames.cs +++ b/samples/on-demand-sandbox/shared/ActivityNames.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace Microsoft.DurableTask.Samples.Serverless.Shared; +namespace Microsoft.DurableTask.Samples.OnDemandSandbox.Shared; public static class ActivityNames { diff --git a/samples/serverless/shared/shared.csproj b/samples/on-demand-sandbox/shared/shared.csproj similarity index 53% rename from samples/serverless/shared/shared.csproj rename to samples/on-demand-sandbox/shared/shared.csproj index 2dc57c0a..4767e89c 100644 --- a/samples/serverless/shared/shared.csproj +++ b/samples/on-demand-sandbox/shared/shared.csproj @@ -3,8 +3,8 @@ net10.0 enable - ServerlessShared - Microsoft.DurableTask.Samples.Serverless.Shared + OnDemandSandboxShared + Microsoft.DurableTask.Samples.OnDemandSandbox.Shared diff --git a/samples/serverless/main-app/Program.cs b/samples/serverless/main-app/Program.cs deleted file mode 100644 index 1e6f4683..00000000 --- a/samples/serverless/main-app/Program.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Azure.Core; -using Azure.Identity; -using Microsoft.DurableTask; -using Microsoft.DurableTask.Client; -using Microsoft.DurableTask.Client.AzureManaged; -using Microsoft.DurableTask.Samples.Serverless.MainApp; -using Microsoft.DurableTask.Worker; -using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -const string Input = "serverless-sample"; - -HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); -string endpoint = builder.Configuration["ServerlessSample:EndpointAddress"]!; -string taskHub = builder.Configuration["ServerlessSample:TaskHubName"]!; -TokenCredential credential = new DefaultAzureCredential(); -builder.Logging.AddSimpleConsole(options => -{ - options.SingleLine = true; - options.UseUtcTimestamp = true; - options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ "; -}); - -builder.Services.AddDurableTaskWorker(workerBuilder => -{ - workerBuilder.AddTasks(tasks => tasks.AddAllGeneratedTasks()); - workerBuilder.UseDurableTaskScheduler(options => - { - options.EndpointAddress = endpoint; - options.TaskHubName = taskHub; - options.Credential = credential; - }); - - workerBuilder.EnableServerlessActivities(); -}); - -builder.Services.AddDurableTaskClient(clientBuilder => -{ - clientBuilder.UseDurableTaskScheduler(options => - { - options.EndpointAddress = endpoint; - options.TaskHubName = taskHub; - options.Credential = credential; - }); -}); - -using IHost host = builder.Build(); - -await host.StartAsync(); - -DurableTaskClient client = host.Services.GetRequiredService(); -string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( - ServerlessTaskNames.HelloOrchestrator, - input: Input); -OrchestrationMetadata? result = await client.WaitForInstanceCompletionAsync( - instanceId, - getInputsAndOutputs: true); - -Console.WriteLine($"Started orchestration: {instanceId}"); -Console.WriteLine($"Runtime status: {result?.RuntimeStatus}"); -Console.WriteLine($"Output: {result?.SerializedOutput ?? ""}"); - -await host.StopAsync(); diff --git a/samples/serverless/main-app/WorkerProfiles.cs b/samples/serverless/main-app/WorkerProfiles.cs deleted file mode 100644 index ea41ecf4..00000000 --- a/samples/serverless/main-app/WorkerProfiles.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.DurableTask.Worker.AzureManaged.Serverless; -using Microsoft.DurableTask.Samples.Serverless.Shared; - -namespace Microsoft.DurableTask.Samples.Serverless.MainApp; - -[ServerlessWorkerProfile("default")] -internal sealed class DefaultServerlessWorkerProfile : IServerlessWorkerProfile -{ - public void Configure(ServerlessOptions options) - { - options.ContainerImage = Environment.GetEnvironmentVariable("DTS_SERVERLESS_CONTAINER_IMAGE") ?? "serverless-remote-worker:local"; - options.Cpu = "1000m"; - options.Memory = "2048Mi"; - options.MaxConcurrentActivities = 1; - options.AddActivity(ActivityNames.RemoteHello); - } -} diff --git a/samples/serverless/main-app/appsettings.json b/samples/serverless/main-app/appsettings.json deleted file mode 100644 index 92ad688a..00000000 --- a/samples/serverless/main-app/appsettings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "ServerlessSample": { - "EndpointAddress": "https://", - "TaskHubName": "ServerlessPocHub" - } -} diff --git a/src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj b/src/Extensions/AzureManagedOnDemandSandbox/AzureManagedOnDemandSandbox.csproj similarity index 77% rename from src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj rename to src/Extensions/AzureManagedOnDemandSandbox/AzureManagedOnDemandSandbox.csproj index 413b65ea..65bfc65f 100644 --- a/src/Extensions/AzureManagedServerless/AzureManagedServerless.csproj +++ b/src/Extensions/AzureManagedOnDemandSandbox/AzureManagedOnDemandSandbox.csproj @@ -2,8 +2,8 @@ net6.0;net8.0;net10.0 - Azure Managed serverless activities support for Durable Task. - Microsoft.DurableTask.AzureManaged.Serverless + Azure Managed on-demand sandbox activities support for Durable Task. + Microsoft.DurableTask.AzureManaged.OnDemandSandbox true diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs new file mode 100644 index 00000000..564224df --- /dev/null +++ b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; + +namespace Microsoft.DurableTask.Client.AzureManaged; + +/// +/// Client for DTS on-demand sandbox activity management operations. +/// +public sealed class OnDemandSandboxActivitiesClient +{ + readonly Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client; + + /// + /// Initializes a new instance of the class. + /// + /// The generated gRPC client used to call DTS on-demand sandbox management operations. + internal OnDemandSandboxActivitiesClient(Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client) + { + this.client = client; + } + + /// + /// Removes an on-demand sandbox activity declaration for a worker profile. + /// + /// The worker profile ID whose declaration should be removed. + /// The cancellation token used to cancel the request. + /// A task that completes when DTS removes the declaration. + public Task RemoveOnDemandSandboxActivityDeclarationAsync( + string workerProfileId, + CancellationToken cancellation = default) + => this.client.RemoveOnDemandSandboxActivityDeclarationAsync(workerProfileId, cancellation); +} diff --git a/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientExtensions.cs similarity index 56% rename from src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs rename to src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientExtensions.cs index 05de3530..0cc714cd 100644 --- a/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientExtensions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientExtensions.cs @@ -2,47 +2,47 @@ // Licensed under the MIT License. using Grpc.Core; -using Proto = Microsoft.DurableTask.Protobuf.Serverless; +using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; namespace Microsoft.DurableTask.Client.AzureManaged; /// -/// Extension methods for the generated serverless activities gRPC client. +/// Extension methods for the generated on-demand sandbox activities gRPC client. /// -public static class ServerlessActivitiesClientExtensions +public static class OnDemandSandboxActivitiesClientExtensions { /// - /// Removes a serverless activity declaration for a worker profile using task hub metadata already configured on the gRPC channel. + /// Removes an on-demand sandbox activity declaration for a worker profile using task hub metadata already configured on the gRPC channel. /// - /// The generated serverless activities gRPC client. + /// The generated on-demand sandbox activities gRPC client. /// The worker profile ID whose declaration should be removed. /// The cancellation token used to cancel the request. /// A task that completes when DTS removes the declaration. - public static Task RemoveServerlessActivityDeclarationAsync( - this Proto.ServerlessActivities.ServerlessActivitiesClient client, + public static Task RemoveOnDemandSandboxActivityDeclarationAsync( + this Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client, string workerProfileId, CancellationToken cancellation = default) { - return RemoveServerlessActivityDeclarationCoreAsync( + return RemoveOnDemandSandboxActivityDeclarationCoreAsync( client, workerProfileId, cancellation); } - static async Task RemoveServerlessActivityDeclarationCoreAsync( - Proto.ServerlessActivities.ServerlessActivitiesClient client, + static async Task RemoveOnDemandSandboxActivityDeclarationCoreAsync( + Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client, string workerProfileId, CancellationToken cancellation) { ArgumentNullException.ThrowIfNull(client); ValidateRequired(workerProfileId, nameof(workerProfileId), "Worker profile ID is required."); - Proto.RemoveServerlessActivityDeclarationRequest request = new() + Proto.RemoveOnDemandSandboxActivityDeclarationRequest request = new() { WorkerProfileId = workerProfileId, }; - using AsyncUnaryCall call = client.RemoveServerlessActivityDeclarationAsync( + using AsyncUnaryCall call = client.RemoveOnDemandSandboxActivityDeclarationAsync( request, headers: null, cancellationToken: cancellation); diff --git a/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientServiceCollectionExtensions.cs b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs similarity index 54% rename from src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientServiceCollectionExtensions.cs rename to src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs index ff0ed57a..fe08cebd 100644 --- a/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClientServiceCollectionExtensions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs @@ -5,30 +5,30 @@ using Microsoft.DurableTask.Client.Grpc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Proto = Microsoft.DurableTask.Protobuf.Serverless; +using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; namespace Microsoft.DurableTask.Client.AzureManaged; /// -/// Extension methods for registering DTS serverless activity management clients. +/// Extension methods for registering DTS on-demand sandbox activity management clients. /// -public static class ServerlessActivitiesClientServiceCollectionExtensions +public static class OnDemandSandboxActivitiesClientServiceCollectionExtensions { /// - /// Adds a DTS serverless activity management client using the default Durable Task client configuration. + /// Adds a DTS on-demand sandbox activity management client using the default Durable Task client configuration. /// /// The service collection to configure. /// The original service collection, for call chaining. - public static IServiceCollection AddDurableTaskSchedulerServerlessActivitiesClient(this IServiceCollection services) - => AddDurableTaskSchedulerServerlessActivitiesClient(services, Options.DefaultName); + public static IServiceCollection AddDurableTaskSchedulerOnDemandSandboxActivitiesClient(this IServiceCollection services) + => AddDurableTaskSchedulerOnDemandSandboxActivitiesClient(services, Options.DefaultName); /// - /// Adds a DTS serverless activity management client using a named Durable Task client configuration. + /// Adds a DTS on-demand sandbox activity management client using a named Durable Task client configuration. /// /// The service collection to configure. /// The Durable Task client name whose scheduler channel should be reused. /// The original service collection, for call chaining. - public static IServiceCollection AddDurableTaskSchedulerServerlessActivitiesClient( + public static IServiceCollection AddDurableTaskSchedulerOnDemandSandboxActivitiesClient( this IServiceCollection services, string clientName) { @@ -43,15 +43,15 @@ public static IServiceCollection AddDurableTaskSchedulerServerlessActivitiesClie if (options.CallInvoker is { } callInvoker) { - return new ServerlessActivitiesClient(new Proto.ServerlessActivities.ServerlessActivitiesClient(callInvoker)); + return new OnDemandSandboxActivitiesClient(new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)); } if (options.Channel is GrpcChannel channel) { - return new ServerlessActivitiesClient(new Proto.ServerlessActivities.ServerlessActivitiesClient(channel.CreateCallInvoker())); + return new OnDemandSandboxActivitiesClient(new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(channel.CreateCallInvoker())); } - throw new InvalidOperationException("DTS serverless activity management requires a configured Durable Task Scheduler client."); + throw new InvalidOperationException("DTS on-demand sandbox activity management requires a configured Durable Task Scheduler client."); }); return services; } diff --git a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs b/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs similarity index 58% rename from src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs rename to src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs index 3b41095f..babe7c52 100644 --- a/src/Extensions/AzureManagedServerless/DurableTaskSchedulerServerlessWorkerExtensions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs @@ -4,8 +4,8 @@ using System.Collections.Generic; using System.Linq; using Grpc.Net.Client; -using Microsoft.DurableTask.Protobuf.Serverless; -using Microsoft.DurableTask.Worker.AzureManaged.Serverless; +using Microsoft.DurableTask.Protobuf.OnDemandSandbox; +using Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; using Microsoft.DurableTask.Worker.Grpc; using Microsoft.DurableTask.Worker.Grpc.Internal; using Microsoft.Extensions.DependencyInjection; @@ -16,69 +16,52 @@ namespace Microsoft.DurableTask.Worker.AzureManaged; /// -/// Extension methods for configuring Azure Managed Durable Task workers with serverless activity support. +/// Extension methods for configuring Azure Managed Durable Task workers with on-demand sandbox activity support. /// -public static class DurableTaskSchedulerServerlessWorkerExtensions +public static class DurableTaskSchedulerOnDemandSandboxWorkerExtensions { - const string EnableServerlessActivitiesWorkerRuntimeErrorMessage = - "EnableServerlessActivities is for declaring serverless activities from the coordinator app. " + - "DTS serverless workers should use UseServerlessWorker instead."; + const string EnableSandboxActivitiesWorkerRuntimeErrorMessage = + "Activity declaration is for declaring on-demand sandbox activities from the coordinator app. " + + "DTS on-demand sandbox workers should use UseSandboxWorker instead."; - const string UseServerlessWorkerNoActivitiesErrorMessage = - "UseServerlessWorker requires at least one registered activity. " + - "Register an activity on this worker before starting the serverless worker."; + const string UseSandboxWorkerNoActivitiesErrorMessage = + "On-demand sandbox workers require at least one registered activity. " + + "Register an activity on this worker before starting the sandbox worker."; /// - /// Enables annotation-based serverless activity declarations with DTS and excludes annotated - /// serverless activities from local execution. + /// Enables annotation-based on-demand sandbox activity declarations with DTS and excludes annotated + /// on-demand sandbox activities from local execution. /// /// The Durable Task worker builder to configure. /// The original builder, for call chaining. - public static IDurableTaskWorkerBuilder EnableServerlessActivities(this IDurableTaskWorkerBuilder builder) + public static IDurableTaskWorkerBuilder EnableSandboxActivities(this IDurableTaskWorkerBuilder builder) { Check.NotNull(builder); - ThrowIfServerlessWorkerRuntime(); + ThrowIfOnDemandSandboxWorkerRuntime(); builder.Services.AddOptions(builder.Name) .PostConfigure>((filters, schedulerOptions) => - ExcludeDeclaredServerlessActivitiesFromLocalExecution(filters, schedulerOptions.Get(builder.Name).TaskHubName)); + ExcludeDeclaredOnDemandSandboxActivitiesFromLocalExecution(filters, schedulerOptions.Get(builder.Name).TaskHubName)); - builder.Services.AddSingleton(sp => CreateServerlessActivityDeclarationHostedService(sp, builder.Name)); + builder.Services.AddSingleton(sp => CreateOnDemandSandboxActivityDeclarationHostedService(sp, builder.Name)); return builder; } /// - /// Configures this worker as a serverless activity worker that connects to DTS to receive and execute - /// serverless activities. Use this on a dedicated worker binary that runs inside serverless infrastructure. + /// Configures this worker as an on-demand sandbox activity worker that connects to DTS to receive and execute + /// on-demand sandbox activities. Use this on a dedicated worker binary that runs inside sandbox infrastructure. /// Runtime configuration is read from environment variables injected by DTS. /// - /// - /// - /// This method is for separate worker binaries only. The coordinator uses - /// to declare and provision the serverless activity configuration. - /// - /// - /// The worker must register at least one activity; serverless workers without registered activities fail at startup. - /// - /// - /// Required environment variables injected automatically by DTS: - /// - /// DTS_ENDPOINT — canonical scheduler endpoint - /// DTS_TASK_HUB — task hub name from the declaration - /// DTS_SUBSTRATE — identifies the sandbox substrate - /// - /// - /// /// The Durable Task worker builder to configure. /// The original builder, for call chaining. - public static IDurableTaskWorkerBuilder UseServerlessWorker(this IDurableTaskWorkerBuilder builder) + public static IDurableTaskWorkerBuilder UseSandboxWorker(this IDurableTaskWorkerBuilder builder) { Check.NotNull(builder); ConfigureDurableTaskSchedulerFromEnvironment(builder); builder.UseWorkItemFilters(); - builder.Services.AddOptions(builder.Name) + builder.Services.AddOptions(builder.Name) .PostConfigure>((options, schedulerOptions) => { ApplyRuntimeTaskHubDefault(options, schedulerOptions.Get(builder.Name).TaskHubName); @@ -86,15 +69,15 @@ public static IDurableTaskWorkerBuilder UseServerlessWorker(this IDurableTaskWor }); builder.Services.AddOptions(builder.Name) - .PostConfigure>((options, runtimeOptions) => - ConfigureServerlessWorkerConcurrency(options, runtimeOptions.Get(builder.Name))); + .PostConfigure>((options, runtimeOptions) => + ConfigureOnDemandSandboxWorkerConcurrency(options, runtimeOptions.Get(builder.Name))); builder.Services.AddOptions(builder.Name) .PostConfigure(IncludeOnlyRegisteredActivities); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddOptions(builder.Name) - .Configure((options, activityTracker) => + .Configure((options, activityTracker) => options.ConfigureActivityNotification(phase => { if (phase == ActivityNotificationPhase.Started) @@ -107,15 +90,15 @@ public static IDurableTaskWorkerBuilder UseServerlessWorker(this IDurableTaskWor } })); - builder.Services.AddSingleton(sp => CreateServerlessActivityWorkerRegistrationHostedService(sp, builder.Name)); + builder.Services.AddSingleton(sp => CreateOnDemandSandboxActivityWorkerRegistrationHostedService(sp, builder.Name)); return builder; } - static void ExcludeDeclaredServerlessActivitiesFromLocalExecution( + static void ExcludeDeclaredOnDemandSandboxActivitiesFromLocalExecution( DurableTaskWorkerWorkItemFilters filters, string taskHub) { - string[] activityNames = ServerlessActivityDeclarationResolver.ResolveDeclaredActivityNames(taskHub); + string[] activityNames = OnDemandSandboxActivityDeclarationResolver.ResolveDeclaredActivityNames(taskHub); if (activityNames.Length == 0) { return; @@ -128,7 +111,7 @@ static void IncludeOnlyRegisteredActivities(DurableTaskWorkerWorkItemFilters fil { if (filters.Activities.Count == 0) { - throw new InvalidOperationException(UseServerlessWorkerNoActivitiesErrorMessage); + throw new InvalidOperationException(UseSandboxWorkerNoActivitiesErrorMessage); } filters.Orchestrations = []; @@ -136,76 +119,76 @@ static void IncludeOnlyRegisteredActivities(DurableTaskWorkerWorkItemFilters fil filters.Entities = []; } - static void ConfigureServerlessWorkerConcurrency( + static void ConfigureOnDemandSandboxWorkerConcurrency( DurableTaskWorkerOptions options, - ServerlessWorkerRuntimeOptions runtimeOptions) + OnDemandSandboxWorkerRuntimeOptions runtimeOptions) { options.Concurrency.MaximumConcurrentActivityWorkItems = runtimeOptions.MaxConcurrentActivities; options.Concurrency.MaximumConcurrentOrchestrationWorkItems = 0; options.Concurrency.MaximumConcurrentEntityWorkItems = 0; } - static void ThrowIfServerlessWorkerRuntime() + static void ThrowIfOnDemandSandboxWorkerRuntime() { - if (IsServerlessWorkerSubstrate(Environment.GetEnvironmentVariable("DTS_SUBSTRATE"))) + if (IsOnDemandSandboxWorkerSubstrate(Environment.GetEnvironmentVariable("DTS_SUBSTRATE"))) { - throw new InvalidOperationException(EnableServerlessActivitiesWorkerRuntimeErrorMessage); + throw new InvalidOperationException(EnableSandboxActivitiesWorkerRuntimeErrorMessage); } } - static ServerlessActivityDeclarationHostedService CreateServerlessActivityDeclarationHostedService( + static OnDemandSandboxActivityDeclarationHostedService CreateOnDemandSandboxActivityDeclarationHostedService( IServiceProvider services, string builderName) { ILoggerFactory loggerFactory = services.GetRequiredService(); - ServerlessWorkerRuntimeOptions runtimeOptions = services.GetRequiredService>().Get(builderName); + OnDemandSandboxWorkerRuntimeOptions runtimeOptions = services.GetRequiredService>().Get(builderName); DurableTaskSchedulerWorkerOptions schedulerOptions = services.GetRequiredService>().Get(builderName); - return new ServerlessActivityDeclarationHostedService( - CreateServerlessActivitiesClient(services, builderName), - ServerlessActivityDeclarationResolver.ResolveDeclarations(schedulerOptions.TaskHubName), + return new OnDemandSandboxActivityDeclarationHostedService( + CreateOnDemandSandboxActivitiesClient(services, builderName), + OnDemandSandboxActivityDeclarationResolver.ResolveDeclarations(schedulerOptions.TaskHubName), runtimeOptions, - loggerFactory.CreateLogger()); + loggerFactory.CreateLogger()); } - static ServerlessActivityWorkerRegistrationHostedService CreateServerlessActivityWorkerRegistrationHostedService( + static OnDemandSandboxActivityWorkerRegistrationHostedService CreateOnDemandSandboxActivityWorkerRegistrationHostedService( IServiceProvider services, string builderName) { - ServerlessWorkerRuntimeOptions options = services.GetRequiredService>().Get(builderName); + OnDemandSandboxWorkerRuntimeOptions options = services.GetRequiredService>().Get(builderName); ILoggerFactory loggerFactory = services.GetRequiredService(); IHostApplicationLifetime? lifetime = services.GetService(); - ServerlessActivityTracker activityTracker = services.GetRequiredService(); + OnDemandSandboxActivityTracker activityTracker = services.GetRequiredService(); DurableTaskWorkerWorkItemFilters filters = services.GetRequiredService>().Get(builderName); - return new ServerlessActivityWorkerRegistrationHostedService( - CreateServerlessActivitiesClient(services, builderName), + return new OnDemandSandboxActivityWorkerRegistrationHostedService( + CreateOnDemandSandboxActivitiesClient(services, builderName), options, ResolveActivityFilterNames(filters.Activities), - loggerFactory.CreateLogger(), + loggerFactory.CreateLogger(), lifetime, activityTracker); } - static ServerlessActivitiesClientAdapter CreateServerlessActivitiesClient(IServiceProvider services, string builderName) + static OnDemandSandboxActivitiesClientAdapter CreateOnDemandSandboxActivitiesClient(IServiceProvider services, string builderName) { GrpcDurableTaskWorkerOptions options = services.GetRequiredService>().Get(builderName); if (options.CallInvoker is { } callInvoker) { - return new ServerlessActivitiesClientAdapter(new ServerlessActivities.ServerlessActivitiesClient(callInvoker)); + return new OnDemandSandboxActivitiesClientAdapter(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)); } if (options.Channel is { } channel) { - return new ServerlessActivitiesClientAdapter( - new ServerlessActivities.ServerlessActivitiesClient(channel.CreateCallInvoker()), + return new OnDemandSandboxActivitiesClientAdapter( + new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(channel.CreateCallInvoker()), attachTaskHubMetadata: false); } - throw new InvalidOperationException("Azure Managed serverless activities require a configured gRPC channel or call invoker."); + throw new InvalidOperationException("Azure Managed on-demand sandbox activities require a configured gRPC channel or call invoker."); } - static void ApplyRuntimeTaskHubDefault(ServerlessWorkerRuntimeOptions options, string taskHubName) + static void ApplyRuntimeTaskHubDefault(OnDemandSandboxWorkerRuntimeOptions options, string taskHubName) { if (string.IsNullOrWhiteSpace(options.TaskHub) && !string.IsNullOrWhiteSpace(taskHubName)) { @@ -230,22 +213,22 @@ static string GetRequiredEnvironmentVariable(string name) { string? value = Environment.GetEnvironmentVariable(name); return string.IsNullOrWhiteSpace(value) - ? throw new InvalidOperationException($"{name} must be injected by DTS for serverless workers.") + ? throw new InvalidOperationException($"{name} must be injected by DTS for on-demand sandbox workers.") : value.Trim(); } - static void ApplyWorkerEnvironmentOverrides(ServerlessWorkerRuntimeOptions options) + static void ApplyWorkerEnvironmentOverrides(OnDemandSandboxWorkerRuntimeOptions options) { // Auto-detect worker mode from DTS_SUBSTRATE, which the backend injects when // launching a sandbox. This is the authoritative signal that this process is a sandbox worker. - if (IsServerlessWorkerSubstrate(Environment.GetEnvironmentVariable("DTS_SUBSTRATE"))) + if (IsOnDemandSandboxWorkerSubstrate(Environment.GetEnvironmentVariable("DTS_SUBSTRATE"))) { - options.Mode = ServerlessMode.ServerlessInclude; + options.Mode = OnDemandSandboxMode.OnDemandSandboxInclude; } ApplyWorkerProfileEnvironmentOverride(profile => options.WorkerProfileId = profile); - if (int.TryParse(Environment.GetEnvironmentVariable("DTS_SERVERLESS_MAX_ACTIVITIES"), out int maxActivities) && maxActivities > 0) + if (int.TryParse(Environment.GetEnvironmentVariable("DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES"), out int maxActivities) && maxActivities > 0) { options.MaxConcurrentActivities = maxActivities; } @@ -260,7 +243,7 @@ static void ApplyWorkerProfileEnvironmentOverride(Action setWorkerProfil } } - static bool IsServerlessWorkerSubstrate(string? substrate) + static bool IsOnDemandSandboxWorkerSubstrate(string? substrate) => string.Equals(substrate, "Sandbox", StringComparison.OrdinalIgnoreCase) || string.Equals(substrate, "AcaSessionPool", StringComparison.OrdinalIgnoreCase); diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/ISandboxWorkerProfile.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/ISandboxWorkerProfile.cs new file mode 100644 index 00000000..569593a9 --- /dev/null +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/ISandboxWorkerProfile.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; + +/// +/// Configures an on-demand sandbox worker profile declaration. +/// +public interface ISandboxWorkerProfile +{ + /// + /// Configures the on-demand sandbox worker profile declaration options. + /// + /// The declaration options to configure. + void Configure(OnDemandSandboxOptions options); +} diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/Logs.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/Logs.cs new file mode 100644 index 00000000..28d66ce8 --- /dev/null +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/Logs.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; + +namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; + +/// +/// Log messages for on-demand sandbox activity services. +/// +static partial class Logs +{ + [LoggerMessage( + EventId = 1, + Level = LogLevel.Information, + Message = "No on-demand sandbox activities discovered for hub={Hub}; skipping declaration")] + public static partial void NoOnDemandSandboxActivitiesForDeclaration(ILogger logger, string hub); + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Information, + Message = "On-demand sandbox activities declared hub={Hub} workerProfile={WorkerProfile} count={Count} image={Image}")] + public static partial void OnDemandSandboxActivitiesDeclared(ILogger logger, string hub, string workerProfile, int count, string image); + + [LoggerMessage( + EventId = 4, + Level = LogLevel.Error, + Message = "On-demand sandbox activity declaration failed hub={Hub}")] + public static partial void OnDemandSandboxActivityDeclarationFailed(ILogger logger, Exception exception, string hub); + + [LoggerMessage( + EventId = 5, + Level = LogLevel.Information, + Message = "No on-demand sandbox activities discovered for worker hub={Hub}; skipping live registration")] + public static partial void NoOnDemandSandboxActivitiesForWorkerRegistration(ILogger logger, string hub); + + [LoggerMessage( + EventId = 6, + Level = LogLevel.Information, + Message = "On-demand sandbox activity worker registered hub={Hub} count={Count} substrate={Substrate} sandboxId={SandboxId}")] + public static partial void OnDemandSandboxActivityWorkerRegistered( + ILogger logger, string hub, int count, Proto.SubstrateKind substrate, string sandboxId); + + [LoggerMessage( + EventId = 7, + Level = LogLevel.Error, + Message = "On-demand sandbox activity worker registration stream failed hub={Hub}")] + public static partial void OnDemandSandboxActivityWorkerRegistrationFailed(ILogger logger, Exception exception, string hub); +} diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivitiesClientAdapter.cs similarity index 51% rename from src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs rename to src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivitiesClientAdapter.cs index 2ad14739..099d1029 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivitiesClientAdapter.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivitiesClientAdapter.cs @@ -2,53 +2,53 @@ // Licensed under the MIT License. using Grpc.Core; -using Proto = Microsoft.DurableTask.Protobuf.Serverless; +using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; -namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; +namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; /// -/// Client abstraction for the serverless activities gRPC service. +/// Client abstraction for the on-demand sandbox activities gRPC service. /// -interface IServerlessActivitiesClient +interface IOnDemandSandboxActivitiesClient { /// - /// Declares serverless activities to DTS. + /// Declares on-demand sandbox activities to DTS. /// /// The declaration message. /// The task hub that owns the declaration. /// The cancellation token. /// The declaration result. - Task DeclareServerlessActivitiesAsync( - Proto.ServerlessActivityDeclaration declaration, + Task DeclareOnDemandSandboxActivitiesAsync( + Proto.OnDemandSandboxActivityDeclaration declaration, string taskHub, CancellationToken cancellationToken); /// - /// Opens a serverless activity worker registration session. + /// Opens an on-demand sandbox activity worker registration session. /// /// The task hub that owns the worker session. /// The cancellation token. /// The worker registration session. - IServerlessActivityWorkerSession OpenServerlessActivityWorkerSession(string taskHub, CancellationToken cancellationToken); + IOnDemandSandboxActivityWorkerSession OpenOnDemandSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken); } /// -/// Client-streaming session used by a serverless activity worker registration. +/// Client-streaming session used by an on-demand sandbox activity worker registration. /// -interface IServerlessActivityWorkerSession : IAsyncDisposable +interface IOnDemandSandboxActivityWorkerSession : IAsyncDisposable { /// /// Writes a worker registration message to the stream. /// /// The message to write. /// A task that completes when the message is written. - Task WriteMessageAsync(Proto.ServerlessActivityWorkerMessage message); + Task WriteMessageAsync(Proto.OnDemandSandboxActivityWorkerMessage message); /// /// Waits for the server to complete the worker registration session. /// /// The worker session result. - Task WaitForCompletionAsync(); + Task WaitForCompletionAsync(); /// /// Completes the request stream and waits for the server response. @@ -58,20 +58,20 @@ interface IServerlessActivityWorkerSession : IAsyncDisposable } /// -/// gRPC-backed implementation of . +/// gRPC-backed implementation of . /// -sealed class ServerlessActivitiesClientAdapter : IServerlessActivitiesClient +sealed class OnDemandSandboxActivitiesClientAdapter : IOnDemandSandboxActivitiesClient { - readonly Proto.ServerlessActivities.ServerlessActivitiesClient client; + readonly Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client; readonly bool attachTaskHubMetadata; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The generated serverless activities gRPC client. + /// The generated on-demand sandbox activities gRPC client. /// True to add per-call task hub metadata when the underlying channel does not already do so. - public ServerlessActivitiesClientAdapter( - Proto.ServerlessActivities.ServerlessActivitiesClient client, + public OnDemandSandboxActivitiesClientAdapter( + Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client, bool attachTaskHubMetadata = true) { this.client = Check.NotNull(client); @@ -79,12 +79,12 @@ public ServerlessActivitiesClientAdapter( } /// - public async Task DeclareServerlessActivitiesAsync( - Proto.ServerlessActivityDeclaration declaration, + public async Task DeclareOnDemandSandboxActivitiesAsync( + Proto.OnDemandSandboxActivityDeclaration declaration, string taskHub, CancellationToken cancellationToken) { - return await this.client.DeclareServerlessActivitiesAsync( + return await this.client.DeclareOnDemandSandboxActivitiesAsync( declaration, headers: this.CreateTaskHubHeaders(taskHub), cancellationToken: cancellationToken) @@ -92,13 +92,13 @@ public ServerlessActivitiesClientAdapter( } /// - public IServerlessActivityWorkerSession OpenServerlessActivityWorkerSession(string taskHub, CancellationToken cancellationToken) + public IOnDemandSandboxActivityWorkerSession OpenOnDemandSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken) { - AsyncClientStreamingCall call = - this.client.ConnectServerlessActivityWorker( + AsyncClientStreamingCall call = + this.client.ConnectOnDemandSandboxActivityWorker( headers: this.CreateTaskHubHeaders(taskHub), cancellationToken: cancellationToken); - return new GrpcServerlessActivityWorkerSession(call); + return new GrpcOnDemandSandboxActivityWorkerSession(call); } Metadata? CreateTaskHubHeaders(string taskHub) => this.attachTaskHubMetadata @@ -106,27 +106,27 @@ public IServerlessActivityWorkerSession OpenServerlessActivityWorkerSession(stri : null; /// - /// gRPC-backed serverless activity worker registration session. + /// gRPC-backed on-demand sandbox activity worker registration session. /// - sealed class GrpcServerlessActivityWorkerSession : IServerlessActivityWorkerSession + sealed class GrpcOnDemandSandboxActivityWorkerSession : IOnDemandSandboxActivityWorkerSession { - readonly AsyncClientStreamingCall call; + readonly AsyncClientStreamingCall call; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The active gRPC client-streaming call. - public GrpcServerlessActivityWorkerSession(AsyncClientStreamingCall call) + public GrpcOnDemandSandboxActivityWorkerSession(AsyncClientStreamingCall call) { this.call = call; } /// - public Task WriteMessageAsync(Proto.ServerlessActivityWorkerMessage message) => + public Task WriteMessageAsync(Proto.OnDemandSandboxActivityWorkerMessage message) => this.call.RequestStream.WriteAsync(message); /// - public async Task WaitForCompletionAsync() => + public async Task WaitForCompletionAsync() => await this.call.ResponseAsync.ConfigureAwait(false); /// diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs similarity index 63% rename from src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs rename to src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs index 9e8bc2f6..7f5a4d66 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityConfiguration.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs @@ -1,17 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Proto = Microsoft.DurableTask.Protobuf.Serverless; +using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; -namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; +namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; /// -/// Builds and normalizes serverless activity protocol messages. +/// Builds and normalizes on-demand sandbox activity protocol messages. /// -static class ServerlessActivityConfiguration +static class OnDemandSandboxActivityConfiguration { /// - /// Resolves configured activity names for serverless activity execution. + /// Resolves configured activity names for on-demand sandbox activity execution. /// /// The configured activity names. /// The normalized activity names. @@ -25,31 +25,31 @@ public static string[] ResolveActivityNames(IEnumerable configuredNames) } /// - /// Builds a serverless activity declaration protocol message. + /// Builds an on-demand sandbox activity declaration protocol message. /// - /// The serverless options. + /// The on-demand sandbox options. /// The activity names included in the declaration. /// The declaration protocol message. - public static Proto.ServerlessActivityDeclaration BuildDeclaration(ServerlessOptions options, IReadOnlyCollection activityNames) + public static Proto.OnDemandSandboxActivityDeclaration BuildDeclaration(OnDemandSandboxOptions options, IReadOnlyCollection activityNames) { Check.NotNull(options); Check.NotNull(activityNames); - ValidateTaskHub(options.TaskHub, "Serverless activity declaration requires a task hub name."); + ValidateTaskHub(options.TaskHub, "On-demand sandbox activity declaration requires a task hub name."); if (activityNames.Count == 0) { - throw new InvalidOperationException("Serverless activity declaration requires at least one activity name."); + throw new InvalidOperationException("On-demand sandbox activity declaration requires at least one activity name."); } - string workerProfileId = NormalizeWorkerProfileId(options.WorkerProfileId, "Serverless activity declaration requires a worker profile ID."); + string workerProfileId = NormalizeWorkerProfileId(options.WorkerProfileId, "On-demand sandbox activity declaration requires a worker profile ID."); if (options.MaxConcurrentActivities <= 0) { - throw new InvalidOperationException("Serverless activity max concurrent activities must be greater than zero."); + throw new InvalidOperationException("On-demand sandbox activity max concurrent activities must be greater than zero."); } - Proto.ServerlessActivityDeclaration declaration = new() + Proto.OnDemandSandboxActivityDeclaration declaration = new() { WorkerProfileId = workerProfileId, Image = BuildImage(options), @@ -65,33 +65,33 @@ public static Proto.ServerlessActivityDeclaration BuildDeclaration(ServerlessOpt } /// - /// Builds the initial serverless activity worker registration message. + /// Builds the initial on-demand sandbox activity worker registration message. /// - /// The serverless options. + /// The on-demand sandbox options. /// The activity handlers registered by the worker process. /// The worker start protocol message. - public static Proto.ServerlessActivityWorkerMessage BuildWorkerStart( - ServerlessWorkerRuntimeOptions options, + public static Proto.OnDemandSandboxActivityWorkerMessage BuildWorkerStart( + OnDemandSandboxWorkerRuntimeOptions options, IReadOnlyCollection registeredActivityNames) { Check.NotNull(options); Check.NotNull(registeredActivityNames); - ValidateTaskHub(options.TaskHub, "Serverless activity worker registration requires a task hub name."); + ValidateTaskHub(options.TaskHub, "On-demand sandbox activity worker registration requires a task hub name."); string[] activityNames = ResolveActivityNames(registeredActivityNames); if (activityNames.Length == 0) { - throw new InvalidOperationException("Serverless activity worker registration requires at least one registered activity."); + throw new InvalidOperationException("On-demand sandbox activity worker registration requires at least one registered activity."); } if (options.MaxConcurrentActivities <= 0) { - throw new InvalidOperationException("Serverless activity worker max concurrent activities must be greater than zero."); + throw new InvalidOperationException("On-demand sandbox activity worker max concurrent activities must be greater than zero."); } - string workerProfileId = NormalizeWorkerProfileId(options.WorkerProfileId, "Serverless activity worker registration requires a worker profile ID."); + string workerProfileId = NormalizeWorkerProfileId(options.WorkerProfileId, "On-demand sandbox activity worker registration requires a worker profile ID."); - Proto.ServerlessActivityWorkerStart start = new() + Proto.OnDemandSandboxActivityWorkerStart start = new() { TaskHub = options.TaskHub, WorkerProfileId = workerProfileId, @@ -101,31 +101,31 @@ public static Proto.ServerlessActivityWorkerMessage BuildWorkerStart( }; start.ActivityNames.AddRange(activityNames); - return new Proto.ServerlessActivityWorkerMessage { Start = start }; + return new Proto.OnDemandSandboxActivityWorkerMessage { Start = start }; } /// - /// Builds a serverless activity worker heartbeat message. + /// Builds an on-demand sandbox activity worker heartbeat message. /// /// The number of activities currently executing. /// The heartbeat protocol message. - public static Proto.ServerlessActivityWorkerMessage BuildWorkerHeartbeat(int activeActivitiesCount) + public static Proto.OnDemandSandboxActivityWorkerMessage BuildWorkerHeartbeat(int activeActivitiesCount) { if (activeActivitiesCount < 0) { - throw new InvalidOperationException("Serverless activity worker active activity count cannot be negative."); + throw new InvalidOperationException("On-demand sandbox activity worker active activity count cannot be negative."); } - return new Proto.ServerlessActivityWorkerMessage + return new Proto.OnDemandSandboxActivityWorkerMessage { - Heartbeat = new Proto.ServerlessActivityWorkerHeartbeat + Heartbeat = new Proto.OnDemandSandboxActivityWorkerHeartbeat { ActiveActivitiesCount = activeActivitiesCount, }, }; } - static Proto.ServerlessActivityImage BuildImage(ServerlessOptions options) + static Proto.OnDemandSandboxActivityImage BuildImage(OnDemandSandboxOptions options) { string? imageRef = Coalesce( options.ContainerImage, @@ -133,21 +133,21 @@ static Proto.ServerlessActivityImage BuildImage(ServerlessOptions options) if (string.IsNullOrWhiteSpace(imageRef)) { - throw new InvalidOperationException("Serverless activity image metadata requires a container image reference."); + throw new InvalidOperationException("On-demand sandbox activity image metadata requires a container image reference."); } - return new Proto.ServerlessActivityImage + return new Proto.OnDemandSandboxActivityImage { ImageRef = imageRef, }; } - static Proto.ServerlessActivityResources BuildResources(ServerlessOptions options) + static Proto.OnDemandSandboxActivityResources BuildResources(OnDemandSandboxOptions options) { - string cpu = NormalizeRequired(options.Cpu, "Serverless activity declaration requires CPU resources."); - string memory = NormalizeRequired(options.Memory, "Serverless activity declaration requires memory resources."); + string cpu = NormalizeRequired(options.Cpu, "On-demand sandbox activity declaration requires CPU resources."); + string memory = NormalizeRequired(options.Memory, "On-demand sandbox activity declaration requires memory resources."); - return new Proto.ServerlessActivityResources + return new Proto.OnDemandSandboxActivityResources { Cpu = cpu, Memory = memory, diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationHostedService.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationHostedService.cs new file mode 100644 index 00000000..b725b77c --- /dev/null +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationHostedService.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; + +namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; + +/// +/// Hosted service that declares on-demand sandbox activities with DTS when the local worker starts. +/// +sealed class OnDemandSandboxActivityDeclarationHostedService : IHostedService +{ + readonly IOnDemandSandboxActivitiesClient client; + readonly IReadOnlyList declarations; + readonly OnDemandSandboxWorkerRuntimeOptions? runtimeOptions; + readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The on-demand sandbox activities client. + /// The on-demand sandbox declaration options. + /// The optional on-demand sandbox worker runtime options. + /// The logger. + public OnDemandSandboxActivityDeclarationHostedService( + IOnDemandSandboxActivitiesClient client, + IReadOnlyList declarations, + OnDemandSandboxWorkerRuntimeOptions? runtimeOptions, + ILogger logger) + { + this.client = Check.NotNull(client); + this.declarations = Check.NotNull(declarations); + this.runtimeOptions = runtimeOptions; + this.logger = Check.NotNull(logger); + } + + /// + /// Initializes a new instance of the class. + /// + /// The on-demand sandbox activities client. + /// The on-demand sandbox declaration options. + /// The optional on-demand sandbox worker runtime options. + /// The logger. + public OnDemandSandboxActivityDeclarationHostedService( + IOnDemandSandboxActivitiesClient client, + OnDemandSandboxOptions declaration, + OnDemandSandboxWorkerRuntimeOptions? runtimeOptions, + ILogger logger) + : this(client, [declaration], runtimeOptions, logger) + { + } + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + if (this.runtimeOptions?.Mode == OnDemandSandboxMode.OnDemandSandboxInclude) + { + return; + } + + if (this.declarations.Count == 0) + { + Logs.NoOnDemandSandboxActivitiesForDeclaration(this.logger, string.Empty); + return; + } + + foreach (OnDemandSandboxOptions options in this.declarations) + { + string[] activityNames = OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames); + if (activityNames.Length == 0) + { + Logs.NoOnDemandSandboxActivitiesForDeclaration(this.logger, options.TaskHub); + continue; + } + + Proto.OnDemandSandboxActivityDeclaration declaration = OnDemandSandboxActivityConfiguration.BuildDeclaration( + options, + activityNames); + try + { + await this.client.DeclareOnDemandSandboxActivitiesAsync( + declaration, + options.TaskHub, + cancellationToken).ConfigureAwait(false); + Logs.OnDemandSandboxActivitiesDeclared( + this.logger, + options.TaskHub, + declaration.WorkerProfileId, + declaration.ActivityNames.Count, + declaration.Image?.ImageRef ?? string.Empty); + } + catch (Exception ex) + { + Logs.OnDemandSandboxActivityDeclarationFailed(this.logger, ex, options.TaskHub); + throw; + } + } + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationResolver.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs similarity index 58% rename from src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationResolver.cs rename to src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs index f5b89cc0..05ef881f 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationResolver.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs @@ -4,29 +4,29 @@ using System.Reflection; using System.Threading; -namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; +namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; /// -/// Resolves serverless activity declarations from worker profile configuration. +/// Resolves on-demand sandbox activity declarations from worker profile configuration. /// -static class ServerlessActivityDeclarationResolver +static class OnDemandSandboxActivityDeclarationResolver { static readonly Lazy Profiles = new(ScanProfiles, LazyThreadSafetyMode.ExecutionAndPublication); /// - /// Resolves serverless declarations for the specified task hub. + /// Resolves on-demand sandbox declarations for the specified task hub. /// /// The task hub name. - /// The resolved serverless declaration options. - public static IReadOnlyList ResolveDeclarations(string taskHub) + /// The resolved on-demand sandbox declaration options. + public static IReadOnlyList ResolveDeclarations(string taskHub) { string normalizedTaskHub = string.IsNullOrWhiteSpace(taskHub) - ? throw new InvalidOperationException("Serverless activity declaration requires a task hub name.") + ? throw new InvalidOperationException("On-demand sandbox activity declaration requires a task hub name.") : taskHub.Trim(); - ServerlessOptions[] declarations = Profiles.Value + OnDemandSandboxOptions[] declarations = Profiles.Value .Select(profile => CreateOptions(normalizedTaskHub, profile)) - .Where(static options => ServerlessActivityConfiguration.ResolveActivityNames(options.ActivityNames).Length > 0) + .Where(static options => OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames).Length > 0) .ToArray(); ValidateActivityOwnership(declarations); @@ -34,14 +34,14 @@ public static IReadOnlyList ResolveDeclarations(string taskHu } /// - /// Resolves activity names declared by serverless worker profiles. + /// Resolves activity names declared by on-demand sandbox worker profiles. /// /// The task hub name. /// The resolved activity names. public static string[] ResolveDeclaredActivityNames(string taskHub) { return ResolveDeclarations(taskHub) - .SelectMany(static options => ServerlessActivityConfiguration.ResolveActivityNames(options.ActivityNames)) + .SelectMany(static options => OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames)) .Distinct(StringComparer.Ordinal) .ToArray(); } @@ -51,14 +51,14 @@ static ProfileMetadata[] ScanProfiles() Dictionary profiles = new(StringComparer.Ordinal); foreach (Type type in GetCandidateTypes()) { - if (type.GetCustomAttribute() is not { } profile) + if (type.GetCustomAttribute() is not { } profile) { continue; } if (profiles.ContainsKey(profile.WorkerProfileId)) { - throw new InvalidOperationException($"Serverless worker profile '{profile.WorkerProfileId}' is declared more than once."); + throw new InvalidOperationException($"On-demand sandbox worker profile '{profile.WorkerProfileId}' is declared more than once."); } profiles.Add(profile.WorkerProfileId, new ProfileMetadata(profile.WorkerProfileId, type)); @@ -67,11 +67,11 @@ static ProfileMetadata[] ScanProfiles() return profiles.Values.ToArray(); } - static ServerlessOptions CreateOptions( + static OnDemandSandboxOptions CreateOptions( string taskHub, ProfileMetadata profile) { - ServerlessOptions options = new() + OnDemandSandboxOptions options = new() { TaskHub = taskHub, WorkerProfileId = profile.WorkerProfileId, @@ -81,16 +81,16 @@ static ServerlessOptions CreateOptions( return options; } - static void ConfigureProfile(Type profileType, ServerlessOptions options) + static void ConfigureProfile(Type profileType, OnDemandSandboxOptions options) { - if (!typeof(IServerlessWorkerProfile).IsAssignableFrom(profileType)) + if (!typeof(ISandboxWorkerProfile).IsAssignableFrom(profileType)) { return; } object? instance = Activator.CreateInstance(profileType, nonPublic: true) - ?? throw new InvalidOperationException($"Serverless worker profile '{profileType.FullName}' could not be created."); - ((IServerlessWorkerProfile)instance).Configure(options); + ?? throw new InvalidOperationException($"On-demand sandbox worker profile '{profileType.FullName}' could not be created."); + ((ISandboxWorkerProfile)instance).Configure(options); } static IEnumerable GetCandidateTypes() @@ -119,17 +119,17 @@ static IEnumerable GetCandidateTypes() } } - static void ValidateActivityOwnership(IEnumerable declarations) + static void ValidateActivityOwnership(IEnumerable declarations) { Dictionary activityOwners = new(StringComparer.Ordinal); - foreach (ServerlessOptions declaration in declarations) + foreach (OnDemandSandboxOptions declaration in declarations) { - foreach (string activityName in ServerlessActivityConfiguration.ResolveActivityNames(declaration.ActivityNames)) + foreach (string activityName in OnDemandSandboxActivityConfiguration.ResolveActivityNames(declaration.ActivityNames)) { if (activityOwners.TryGetValue(activityName, out string? existingProfile) && !string.Equals(existingProfile, declaration.WorkerProfileId, StringComparison.Ordinal)) { - throw new InvalidOperationException($"Serverless activity '{activityName}' is assigned to both worker profile '{existingProfile}' and '{declaration.WorkerProfileId}'."); + throw new InvalidOperationException($"On-demand sandbox activity '{activityName}' is assigned to both worker profile '{existingProfile}' and '{declaration.WorkerProfileId}'."); } activityOwners[activityName] = declaration.WorkerProfileId; diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityTracker.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityTracker.cs similarity index 84% rename from src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityTracker.cs rename to src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityTracker.cs index 36237ce2..c45a3c6c 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityTracker.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityTracker.cs @@ -1,12 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; +namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; /// -/// Tracks activity execution state for a serverless worker process. +/// Tracks activity execution state for an on-demand sandbox worker process. /// -sealed class ServerlessActivityTracker +sealed class OnDemandSandboxActivityTracker { int activeActivityCount; diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs similarity index 77% rename from src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs rename to src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs index fa00e396..f9fe1b2d 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityWorkerRegistrationHostedService.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs @@ -5,45 +5,45 @@ using Grpc.Core; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Proto = Microsoft.DurableTask.Protobuf.Serverless; +using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; -namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; +namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; /// -/// Hosted service that registers a running process as a serverless activity worker with DTS. +/// Hosted service that registers a running process as an on-demand sandbox activity worker with DTS. /// -sealed class ServerlessActivityWorkerRegistrationHostedService : IHostedService, IAsyncDisposable +sealed class OnDemandSandboxActivityWorkerRegistrationHostedService : IHostedService, IAsyncDisposable { readonly object sync = new(); - readonly IServerlessActivitiesClient client; - readonly ServerlessWorkerRuntimeOptions options; + readonly IOnDemandSandboxActivitiesClient client; + readonly OnDemandSandboxWorkerRuntimeOptions options; readonly IReadOnlyCollection registeredActivityNames; - readonly ILogger logger; + readonly ILogger logger; readonly IHostApplicationLifetime? lifetime; - readonly ServerlessActivityTracker? activityTracker; + readonly OnDemandSandboxActivityTracker? activityTracker; readonly Random reconnectJitter; readonly SemaphoreSlim streamSync = new(1, 1); CancellationTokenSource? cts; - IServerlessActivityWorkerSession? session; + IOnDemandSandboxActivityWorkerSession? session; Task? pump; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - /// The serverless activities client. - /// The serverless worker runtime options. + /// The on-demand sandbox activities client. + /// The on-demand sandbox worker runtime options. /// The activity handlers registered by this worker process. /// The logger. /// The optional application lifetime used to stop the host when a non-retriable registration stream failure occurs. /// The optional activity tracker used to report live in-flight activity count. /// The optional random source used to jitter reconnect delays. - public ServerlessActivityWorkerRegistrationHostedService( - IServerlessActivitiesClient client, - ServerlessWorkerRuntimeOptions options, + public OnDemandSandboxActivityWorkerRegistrationHostedService( + IOnDemandSandboxActivitiesClient client, + OnDemandSandboxWorkerRuntimeOptions options, IReadOnlyCollection registeredActivityNames, - ILogger logger, + ILogger logger, IHostApplicationLifetime? lifetime = null, - ServerlessActivityTracker? activityTracker = null, + OnDemandSandboxActivityTracker? activityTracker = null, Random? reconnectJitter = null) { this.client = Check.NotNull(client); @@ -58,16 +58,16 @@ public ServerlessActivityWorkerRegistrationHostedService( /// public Task StartAsync(CancellationToken cancellationToken) { - if (this.options.Mode != ServerlessMode.ServerlessInclude) + if (this.options.Mode != OnDemandSandboxMode.OnDemandSandboxInclude) { this.pump = Task.CompletedTask; return Task.CompletedTask; } - string[] activityNames = ServerlessActivityConfiguration.ResolveActivityNames(this.registeredActivityNames); + string[] activityNames = OnDemandSandboxActivityConfiguration.ResolveActivityNames(this.registeredActivityNames); if (activityNames.Length == 0) { - Logs.NoServerlessActivitiesForWorkerRegistration(this.logger, this.options.TaskHub); + Logs.NoOnDemandSandboxActivitiesForWorkerRegistration(this.logger, this.options.TaskHub); this.pump = Task.CompletedTask; return Task.CompletedTask; } @@ -89,7 +89,7 @@ public Task StartAsync(CancellationToken cancellationToken) public async Task StopAsync(CancellationToken cancellationToken) { CancellationTokenSource? localCts; - IServerlessActivityWorkerSession? localSession; + IOnDemandSandboxActivityWorkerSession? localSession; Task? localPump; lock (this.sync) { @@ -167,7 +167,7 @@ internal static TimeSpan ComputeJitteredReconnectDelay(TimeSpan retryDelay, Rand return TimeSpan.FromTicks(jitteredTicks); } - static async ValueTask DisposeSessionAsync(IServerlessActivityWorkerSession registrationSession) + static async ValueTask DisposeSessionAsync(IOnDemandSandboxActivityWorkerSession registrationSession) { try { @@ -193,15 +193,15 @@ async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancell TimeSpan retryDelay = this.GetInitialRetryDelay(); while (!cancellationToken.IsCancellationRequested) { - IServerlessActivityWorkerSession? registrationSession = null; + IOnDemandSandboxActivityWorkerSession? registrationSession = null; try { - registrationSession = this.client.OpenServerlessActivityWorkerSession(this.options.TaskHub, cancellationToken); + registrationSession = this.client.OpenOnDemandSandboxActivityWorkerSession(this.options.TaskHub, cancellationToken); this.SetCurrentSession(registrationSession); - Proto.ServerlessActivityWorkerMessage startMessage = ServerlessActivityConfiguration.BuildWorkerStart(this.options, this.registeredActivityNames); + Proto.OnDemandSandboxActivityWorkerMessage startMessage = OnDemandSandboxActivityConfiguration.BuildWorkerStart(this.options, this.registeredActivityNames); await this.WriteSessionMessageAsync(registrationSession, startMessage, cancellationToken).ConfigureAwait(false); - Logs.ServerlessActivityWorkerRegistered( + Logs.OnDemandSandboxActivityWorkerRegistered( this.logger, startMessage.Start.TaskHub, activityCount, @@ -217,13 +217,13 @@ async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancell } catch (Exception ex) when (!IsRetriableRegistrationFailure(ex)) { - Logs.ServerlessActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); + Logs.OnDemandSandboxActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); this.lifetime?.StopApplication(); break; } catch (Exception ex) { - Logs.ServerlessActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); + Logs.OnDemandSandboxActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); await this.DelayBeforeReconnectAsync(retryDelay, cancellationToken).ConfigureAwait(false); retryDelay = this.GetNextRetryDelay(retryDelay); } @@ -239,12 +239,12 @@ async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancell } async Task RunRegistrationSessionAsync( - IServerlessActivityWorkerSession registrationSession, + IOnDemandSandboxActivityWorkerSession registrationSession, CancellationToken cancellationToken) { using CancellationTokenSource heartbeatCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); Task heartbeatTask = this.PumpHeartbeatsAsync(registrationSession, heartbeatCts.Token); - Task completionTask = registrationSession.WaitForCompletionAsync(); + Task completionTask = registrationSession.WaitForCompletionAsync(); Task completedTask = await Task.WhenAny(heartbeatTask, completionTask).ConfigureAwait(false); if (ReferenceEquals(completedTask, completionTask)) @@ -270,7 +270,7 @@ async Task RunRegistrationSessionAsync( } async Task PumpHeartbeatsAsync( - IServerlessActivityWorkerSession registrationSession, + IOnDemandSandboxActivityWorkerSession registrationSession, CancellationToken cancellationToken) { using PeriodicTimer timer = new(this.options.HeartbeatInterval); @@ -279,14 +279,14 @@ async Task PumpHeartbeatsAsync( int activeActivitiesCount = this.activityTracker?.InFlightCount ?? 0; await this.WriteSessionMessageAsync( registrationSession, - ServerlessActivityConfiguration.BuildWorkerHeartbeat(activeActivitiesCount), + OnDemandSandboxActivityConfiguration.BuildWorkerHeartbeat(activeActivitiesCount), cancellationToken).ConfigureAwait(false); } } async Task WriteSessionMessageAsync( - IServerlessActivityWorkerSession registrationSession, - Proto.ServerlessActivityWorkerMessage message, + IOnDemandSandboxActivityWorkerSession registrationSession, + Proto.OnDemandSandboxActivityWorkerMessage message, CancellationToken cancellationToken) { await this.streamSync.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -302,7 +302,7 @@ async Task WriteSessionMessageAsync( } async Task CompleteSessionAsync( - IServerlessActivityWorkerSession registrationSession, + IOnDemandSandboxActivityWorkerSession registrationSession, CancellationToken cancellationToken) { await this.streamSync.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -316,7 +316,7 @@ async Task CompleteSessionAsync( } } - void SetCurrentSession(IServerlessActivityWorkerSession registrationSession) + void SetCurrentSession(IOnDemandSandboxActivityWorkerSession registrationSession) { lock (this.sync) { @@ -324,7 +324,7 @@ void SetCurrentSession(IServerlessActivityWorkerSession registrationSession) } } - void ClearCurrentSession(IServerlessActivityWorkerSession registrationSession) + void ClearCurrentSession(IOnDemandSandboxActivityWorkerSession registrationSession) { lock (this.sync) { diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs similarity index 64% rename from src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs rename to src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs index 6c77c4e2..bd14c968 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessOptions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs @@ -3,12 +3,12 @@ using System.Reflection; -namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; +namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; /// -/// Options for declaring serverless activities and the worker image DTS should start for them. +/// Options for declaring on-demand sandbox activities and the worker image DTS should start for them. /// -public sealed class ServerlessOptions +public sealed class OnDemandSandboxOptions { /// /// Default worker profile ID used when no profile is specified. @@ -16,94 +16,94 @@ public sealed class ServerlessOptions internal const string DefaultWorkerProfileId = "default"; /// - /// Gets or sets the task hub where the serverless activity declaration is stored. + /// Gets or sets the task hub where the on-demand sandbox activity declaration is stored. /// public string TaskHub { get; set; } = string.Empty; /// - /// Gets or sets the worker profile ID used for the serverless activity pool. + /// Gets or sets the worker profile ID used for the on-demand sandbox activity pool. /// public string WorkerProfileId { get; set; } = DefaultWorkerProfileId; /// - /// Gets or sets the full container image reference for serverless workers. + /// Gets or sets the full container image reference for on-demand sandbox workers. /// public string? ContainerImage { get; set; } /// - /// Gets or sets the registry server for the serverless worker image. + /// Gets or sets the registry server for the on-demand sandbox worker image. /// public string? RegistryServer { get; set; } /// - /// Gets or sets the repository for the serverless worker image. + /// Gets or sets the repository for the on-demand sandbox worker image. /// public string? Repository { get; set; } /// - /// Gets or sets the tag for the serverless worker image. + /// Gets or sets the tag for the on-demand sandbox worker image. /// public string? Tag { get; set; } /// - /// Gets or sets the digest for the serverless worker image. + /// Gets or sets the digest for the on-demand sandbox worker image. /// public string? ImageDigest { get; set; } /// - /// Gets or sets the CPU quantity declared for each serverless sandbox. + /// Gets or sets the CPU quantity declared for each sandbox. /// public string Cpu { get; set; } = "1000m"; /// - /// Gets or sets the memory quantity declared for each serverless sandbox. + /// Gets or sets the memory quantity declared for each sandbox. /// public string Memory { get; set; } = "2048Mi"; /// - /// Gets custom environment variables DTS should provide to serverless workers created from this declaration. + /// Gets custom environment variables DTS should provide to on-demand sandbox workers created from this declaration. /// DTS-owned runtime variables such as DTS_ENDPOINT, DTS_TASK_HUB, and /// DTS_SANDBOX_ID are injected by the backend and should not be supplied here. /// public IDictionary EnvironmentVariables { get; } = new Dictionary(StringComparer.Ordinal); /// - /// Gets the sandbox entrypoint declared for serverless workers. + /// Gets the sandbox entrypoint declared for on-demand sandbox workers. /// public IList Entrypoint { get; } = new List(); /// - /// Gets the sandbox command declared for serverless workers. + /// Gets the sandbox command declared for on-demand sandbox workers. /// public IList Cmd { get; } = new List(); /// - /// Gets or sets the maximum number of concurrent activities expected from each serverless worker. + /// Gets or sets the maximum number of concurrent activities expected from each on-demand sandbox worker. /// public int MaxConcurrentActivities { get; set; } = 100; /// - /// Gets the serverless activity names to declare. Remote workers report their registered + /// Gets the on-demand sandbox activity names to declare. Remote workers report their registered /// activities separately when they connect. /// internal IList ActivityNames { get; } = new List(); /// - /// Adds an activity name to the serverless worker profile declaration. + /// Adds an activity name to the on-demand sandbox worker profile declaration. /// /// The activity name. public void AddActivity(string activityName) { if (string.IsNullOrWhiteSpace(activityName)) { - throw new ArgumentException("Serverless activity name cannot be empty.", nameof(activityName)); + throw new ArgumentException("On-demand sandbox activity name cannot be empty.", nameof(activityName)); } this.ActivityNames.Add(activityName.Trim()); } /// - /// Adds an activity to the serverless worker profile declaration using its durable task name. + /// Adds an activity to the on-demand sandbox worker profile declaration using its durable task name. /// /// The activity type. public void AddActivity() where TActivity : class, ITaskActivity diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs new file mode 100644 index 00000000..5d58c917 --- /dev/null +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; + +/// +/// Declares an on-demand sandbox worker profile that DTS can start for activities declared by the profile. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class OnDemandSandboxWorkerProfileAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The worker profile ID. + public OnDemandSandboxWorkerProfileAttribute(string workerProfileId) + { + this.WorkerProfileId = string.IsNullOrWhiteSpace(workerProfileId) + ? throw new ArgumentException("On-demand sandbox worker profile ID cannot be empty.", nameof(workerProfileId)) + : workerProfileId.Trim(); + } + + /// + /// Gets the worker profile ID. + /// + public string WorkerProfileId { get; } +} diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerRuntimeOptions.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs similarity index 54% rename from src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerRuntimeOptions.cs rename to src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs index 16852746..373545a0 100644 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerRuntimeOptions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs @@ -1,41 +1,41 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; +namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; /// -/// Defines how a worker participates in serverless activity execution. +/// Defines how a worker participates in on-demand sandbox activity execution. /// -internal enum ServerlessMode +internal enum OnDemandSandboxMode { /// - /// The worker is not running inside serverless infrastructure. + /// The worker is not running inside on-demand sandbox infrastructure. /// LocalExclude, /// - /// The worker runs inside serverless infrastructure and executes only serverless activities. + /// The worker runs inside on-demand sandbox infrastructure and executes only on-demand sandbox activities. /// - ServerlessInclude, + OnDemandSandboxInclude, } /// -/// Internal runtime settings for a sandbox serverless worker process. +/// Internal runtime settings for an on-demand sandbox worker process. /// -internal sealed class ServerlessWorkerRuntimeOptions +internal sealed class OnDemandSandboxWorkerRuntimeOptions { /// - /// Gets or sets the task hub used by serverless worker registration. + /// Gets or sets the task hub used by on-demand sandbox worker registration. /// public string TaskHub { get; set; } = string.Empty; /// - /// Gets or sets the worker profile ID used by serverless worker registration. + /// Gets or sets the worker profile ID used by on-demand sandbox worker registration. /// - public string WorkerProfileId { get; set; } = ServerlessOptions.DefaultWorkerProfileId; + public string WorkerProfileId { get; set; } = OnDemandSandboxOptions.DefaultWorkerProfileId; /// - /// Gets or sets the maximum number of concurrent activities expected from this serverless worker. + /// Gets or sets the maximum number of concurrent activities expected from this on-demand sandbox worker. /// public int MaxConcurrentActivities { get; set; } = 100; @@ -55,7 +55,7 @@ internal sealed class ServerlessWorkerRuntimeOptions public TimeSpan WorkerRegistrationRetryMaxDelay { get; set; } = TimeSpan.FromSeconds(30); /// - /// Gets or sets the worker mode for serverless activity execution. Set automatically from the runtime environment. + /// Gets or sets the worker mode for on-demand sandbox activity execution. Set automatically from the runtime environment. /// - public ServerlessMode Mode { get; set; } = ServerlessMode.LocalExclude; + public OnDemandSandboxMode Mode { get; set; } = OnDemandSandboxMode.LocalExclude; } diff --git a/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClient.cs b/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClient.cs deleted file mode 100644 index 01f32ae7..00000000 --- a/src/Extensions/AzureManagedServerless/Client/ServerlessActivitiesClient.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Proto = Microsoft.DurableTask.Protobuf.Serverless; - -namespace Microsoft.DurableTask.Client.AzureManaged; - -/// -/// Client for DTS serverless activity management operations. -/// -public sealed class ServerlessActivitiesClient -{ - readonly Proto.ServerlessActivities.ServerlessActivitiesClient client; - - /// - /// Initializes a new instance of the class. - /// - /// The generated gRPC client used to call DTS serverless management operations. - internal ServerlessActivitiesClient(Proto.ServerlessActivities.ServerlessActivitiesClient client) - { - this.client = client; - } - - /// - /// Removes a serverless activity declaration for a worker profile. - /// - /// The worker profile ID whose declaration should be removed. - /// The cancellation token used to cancel the request. - /// A task that completes when DTS removes the declaration. - public Task RemoveServerlessActivityDeclarationAsync( - string workerProfileId, - CancellationToken cancellation = default) - => this.client.RemoveServerlessActivityDeclarationAsync(workerProfileId, cancellation); -} diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/IServerlessWorkerProfile.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/IServerlessWorkerProfile.cs deleted file mode 100644 index 69588436..00000000 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/IServerlessWorkerProfile.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; - -/// -/// Configures a serverless worker profile declaration. -/// -public interface IServerlessWorkerProfile -{ - /// - /// Configures the serverless worker profile declaration options. - /// - /// The declaration options to configure. - void Configure(ServerlessOptions options); -} diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/Logs.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/Logs.cs deleted file mode 100644 index 3f1bbfa0..00000000 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/Logs.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Extensions.Logging; -using Proto = Microsoft.DurableTask.Protobuf.Serverless; - -namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; - -/// -/// Log messages for serverless activity services. -/// -static partial class Logs -{ - [LoggerMessage( - EventId = 1, - Level = LogLevel.Information, - Message = "No serverless activities discovered for hub={Hub}; skipping declaration")] - public static partial void NoServerlessActivitiesForDeclaration(ILogger logger, string hub); - - [LoggerMessage( - EventId = 2, - Level = LogLevel.Information, - Message = "Serverless activities declared hub={Hub} workerProfile={WorkerProfile} count={Count} image={Image}")] - public static partial void ServerlessActivitiesDeclared(ILogger logger, string hub, string workerProfile, int count, string image); - - [LoggerMessage( - EventId = 4, - Level = LogLevel.Error, - Message = "Serverless activity declaration failed hub={Hub}")] - public static partial void ServerlessActivityDeclarationFailed(ILogger logger, Exception exception, string hub); - - [LoggerMessage( - EventId = 5, - Level = LogLevel.Information, - Message = "No serverless activities discovered for worker hub={Hub}; skipping live registration")] - public static partial void NoServerlessActivitiesForWorkerRegistration(ILogger logger, string hub); - - [LoggerMessage( - EventId = 6, - Level = LogLevel.Information, - Message = "Serverless activity worker registered hub={Hub} count={Count} substrate={Substrate} sandboxId={SandboxId}")] - public static partial void ServerlessActivityWorkerRegistered( - ILogger logger, string hub, int count, Proto.SubstrateKind substrate, string sandboxId); - - [LoggerMessage( - EventId = 7, - Level = LogLevel.Error, - Message = "Serverless activity worker registration stream failed hub={Hub}")] - public static partial void ServerlessActivityWorkerRegistrationFailed(ILogger logger, Exception exception, string hub); -} diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs deleted file mode 100644 index 6ebc925e..00000000 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessActivityDeclarationHostedService.cs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Proto = Microsoft.DurableTask.Protobuf.Serverless; - -namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; - -/// -/// Hosted service that declares serverless activities with DTS when the local worker starts. -/// -sealed class ServerlessActivityDeclarationHostedService : IHostedService -{ - readonly IServerlessActivitiesClient client; - readonly IReadOnlyList declarations; - readonly ServerlessWorkerRuntimeOptions? runtimeOptions; - readonly ILogger logger; - - /// - /// Initializes a new instance of the class. - /// - /// The serverless activities client. - /// The serverless declaration options. - /// The optional serverless worker runtime options. - /// The logger. - public ServerlessActivityDeclarationHostedService( - IServerlessActivitiesClient client, - IReadOnlyList declarations, - ServerlessWorkerRuntimeOptions? runtimeOptions, - ILogger logger) - { - this.client = Check.NotNull(client); - this.declarations = Check.NotNull(declarations); - this.runtimeOptions = runtimeOptions; - this.logger = Check.NotNull(logger); - } - - /// - /// Initializes a new instance of the class. - /// - /// The serverless activities client. - /// The serverless declaration options. - /// The optional serverless worker runtime options. - /// The logger. - public ServerlessActivityDeclarationHostedService( - IServerlessActivitiesClient client, - ServerlessOptions declaration, - ServerlessWorkerRuntimeOptions? runtimeOptions, - ILogger logger) - : this(client, [declaration], runtimeOptions, logger) - { - } - - /// - public async Task StartAsync(CancellationToken cancellationToken) - { - if (this.runtimeOptions?.Mode == ServerlessMode.ServerlessInclude) - { - return; - } - - if (this.declarations.Count == 0) - { - Logs.NoServerlessActivitiesForDeclaration(this.logger, string.Empty); - return; - } - - foreach (ServerlessOptions options in this.declarations) - { - string[] activityNames = ServerlessActivityConfiguration.ResolveActivityNames(options.ActivityNames); - if (activityNames.Length == 0) - { - Logs.NoServerlessActivitiesForDeclaration(this.logger, options.TaskHub); - continue; - } - - Proto.ServerlessActivityDeclaration declaration = ServerlessActivityConfiguration.BuildDeclaration( - options, - activityNames); - try - { - await this.client.DeclareServerlessActivitiesAsync( - declaration, - options.TaskHub, - cancellationToken).ConfigureAwait(false); - Logs.ServerlessActivitiesDeclared( - this.logger, - options.TaskHub, - declaration.WorkerProfileId, - declaration.ActivityNames.Count, - declaration.Image?.ImageRef ?? string.Empty); - } - catch (Exception ex) - { - Logs.ServerlessActivityDeclarationFailed(this.logger, ex, options.TaskHub); - throw; - } - } - } - - /// - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; -} diff --git a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerProfileAttribute.cs b/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerProfileAttribute.cs deleted file mode 100644 index 22615120..00000000 --- a/src/Extensions/AzureManagedServerless/Worker/Serverless/ServerlessWorkerProfileAttribute.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Microsoft.DurableTask.Worker.AzureManaged.Serverless; - -/// -/// Declares a serverless worker profile that DTS can start for activities declared by the profile. -/// -[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] -public sealed class ServerlessWorkerProfileAttribute : Attribute -{ - /// - /// Initializes a new instance of the class. - /// - /// The worker profile ID. - public ServerlessWorkerProfileAttribute(string workerProfileId) - { - this.WorkerProfileId = string.IsNullOrWhiteSpace(workerProfileId) - ? throw new ArgumentException("Serverless worker profile ID cannot be empty.", nameof(workerProfileId)) - : workerProfileId.Trim(); - } - - /// - /// Gets the worker profile ID. - /// - public string WorkerProfileId { get; } -} diff --git a/src/Grpc/on_demand_sandbox_activities_service.proto b/src/Grpc/on_demand_sandbox_activities_service.proto new file mode 100644 index 00000000..95b1d53f --- /dev/null +++ b/src/Grpc/on_demand_sandbox_activities_service.proto @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +syntax = "proto3"; + +package microsoft.durabletask.ondemandsandbox; + +option csharp_namespace = "Microsoft.DurableTask.Protobuf.OnDemandSandbox"; + +service OnDemandSandboxActivities { + // Opens a live on-demand sandbox activity worker session. The first message + // must be a start message with static worker metadata. Heartbeats carry + // dynamic state only. Closing the stream deregisters the worker. + rpc ConnectOnDemandSandboxActivityWorker(stream OnDemandSandboxActivityWorkerMessage) returns (OnDemandSandboxActivityWorkerSessionResult); + + // Declares on-demand sandbox activities before any live worker stream exists. + // This is a configuration contract and does not advertise active worker + // capacity. + rpc DeclareOnDemandSandboxActivities(OnDemandSandboxActivityDeclaration) returns (OnDemandSandboxActivityDeclarationResult); + + // Removes an on-demand sandbox activity declaration so the backend stops + // waking new sandbox workers for the specified worker profile. Existing + // workers are not terminated by this RPC. + rpc RemoveOnDemandSandboxActivityDeclaration(RemoveOnDemandSandboxActivityDeclarationRequest) returns (RemoveOnDemandSandboxActivityDeclarationResult); +} + +message OnDemandSandboxActivityWorkerMessage { + oneof message { + OnDemandSandboxActivityWorkerStart start = 1; + OnDemandSandboxActivityWorkerHeartbeat heartbeat = 2; + } +} + +message OnDemandSandboxActivityWorkerStart { + reserved 2; + reserved "worker_instance_id"; + + string task_hub = 1; + int32 max_activities_count = 3; + // Substrate the worker is running in. UNSPECIFIED = legacy (pre-substrate-aware) workers. + SubstrateKind substrate = 4; + // DTS-generated sandbox identifier injected as DTS_SANDBOX_ID. This is not + // the ADC provider sandbox resource id. + string dts_sandbox_identifier = 5; + string worker_profile_id = 6; + // Activity handlers registered by the worker process. DTS validates this + // matches the declaration before advertising worker capacity. + repeated string activity_names = 7; +} + +message OnDemandSandboxActivityWorkerHeartbeat { + int32 active_activities_count = 1; +} + +message OnDemandSandboxActivityWorkerSessionResult { + bool accepted = 1; + string message = 2; +} + +message OnDemandSandboxActivityDeclaration { + string worker_profile_id = 2; + repeated string activity_names = 3; + OnDemandSandboxActivityImage image = 4; + map environment_variables = 5; + int32 max_concurrent_activities = 6; + OnDemandSandboxActivityResources resources = 7; + repeated string entrypoint = 8; + repeated string cmd = 9; +} + +message OnDemandSandboxActivityImage { + string image_ref = 1; +} + +message OnDemandSandboxActivityResources { + string cpu = 1; + string memory = 2; +} + +message OnDemandSandboxActivityDeclarationResult { +} + +message RemoveOnDemandSandboxActivityDeclarationRequest { + string worker_profile_id = 1; +} + +message RemoveOnDemandSandboxActivityDeclarationResult { +} + +// Compute substrate executing the activity worker. +enum SubstrateKind { + SUBSTRATE_KIND_UNSPECIFIED = 0; + SUBSTRATE_KIND_ACA_SESSION_POOL = 1; + SUBSTRATE_KIND_SANDBOX = 2; +} diff --git a/src/Grpc/serverless_activities_service.proto b/src/Grpc/serverless_activities_service.proto deleted file mode 100644 index 153d62db..00000000 --- a/src/Grpc/serverless_activities_service.proto +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -syntax = "proto3"; - -package microsoft.durabletask.serverless; - -option csharp_namespace = "Microsoft.DurableTask.Protobuf.Serverless"; - -service ServerlessActivities { - // Opens a live serverless activity worker session. The first message must be a - // start message with static worker metadata. Heartbeats carry dynamic state - // only. Closing the stream deregisters the worker. - rpc ConnectServerlessActivityWorker(stream ServerlessActivityWorkerMessage) returns (ServerlessActivityWorkerSessionResult); - - // Declares serverless activities before any live worker stream exists. This is a - // configuration contract and does not advertise active worker capacity. - rpc DeclareServerlessActivities(ServerlessActivityDeclaration) returns (ServerlessActivityDeclarationResult); - - // Removes a serverless activity declaration so the backend stops waking new workers - // for the specified worker profile. Existing workers are not terminated by this RPC. - rpc RemoveServerlessActivityDeclaration(RemoveServerlessActivityDeclarationRequest) returns (RemoveServerlessActivityDeclarationResult); -} - -message ServerlessActivityWorkerMessage { - oneof message { - ServerlessActivityWorkerStart start = 1; - ServerlessActivityWorkerHeartbeat heartbeat = 2; - } -} - -message ServerlessActivityWorkerStart { - reserved 2; - reserved "worker_instance_id"; - - string task_hub = 1; - int32 max_activities_count = 3; - // Substrate the worker is running in. UNSPECIFIED = legacy (pre-substrate-aware) workers. - SubstrateKind substrate = 4; - // DTS-generated sandbox identifier injected as DTS_SANDBOX_ID. This is not - // the ADC provider sandbox resource id. - string dts_sandbox_identifier = 5; - string worker_profile_id = 6; - // Activity handlers registered by the worker process. DTS validates this - // matches the declaration before advertising worker capacity. - repeated string activity_names = 7; -} - -message ServerlessActivityWorkerHeartbeat { - int32 active_activities_count = 1; -} - -message ServerlessActivityWorkerSessionResult { - bool accepted = 1; - string message = 2; -} - -message ServerlessActivityDeclaration { - string worker_profile_id = 2; - repeated string activity_names = 3; - ServerlessActivityImage image = 4; - map environment_variables = 5; - int32 max_concurrent_activities = 6; - ServerlessActivityResources resources = 7; - repeated string entrypoint = 8; - repeated string cmd = 9; -} - -message ServerlessActivityImage { - string image_ref = 1; -} - -message ServerlessActivityResources { - string cpu = 1; - string memory = 2; -} - -message ServerlessActivityDeclarationResult { -} - -message RemoveServerlessActivityDeclarationRequest { - string worker_profile_id = 1; -} - -message RemoveServerlessActivityDeclarationResult { -} - -// Compute substrate executing the activity worker. -enum SubstrateKind { - SUBSTRATE_KIND_UNSPECIFIED = 0; - SUBSTRATE_KIND_ACA_SESSION_POOL = 1; - SUBSTRATE_KIND_SANDBOX = 2; -} diff --git a/test/Extensions/AzureManagedServerless.Tests/AzureManagedServerless.Tests.csproj b/test/Extensions/AzureManagedOnDemandSandbox.Tests/AzureManagedOnDemandSandbox.Tests.csproj similarity index 86% rename from test/Extensions/AzureManagedServerless.Tests/AzureManagedServerless.Tests.csproj rename to test/Extensions/AzureManagedOnDemandSandbox.Tests/AzureManagedOnDemandSandbox.Tests.csproj index a03e480e..a1bb8b8f 100644 --- a/test/Extensions/AzureManagedServerless.Tests/AzureManagedServerless.Tests.csproj +++ b/test/Extensions/AzureManagedOnDemandSandbox.Tests/AzureManagedOnDemandSandbox.Tests.csproj @@ -10,7 +10,7 @@ - + diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientExtensionsTests.cs similarity index 68% rename from test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs rename to test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientExtensionsTests.cs index 9db1c9ad..294d9c1f 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesClientExtensionsTests.cs +++ b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientExtensionsTests.cs @@ -4,30 +4,30 @@ using FluentAssertions; using Grpc.Core; using Microsoft.DurableTask.Client.Grpc; -using Microsoft.DurableTask.Protobuf.Serverless; +using Microsoft.DurableTask.Protobuf.OnDemandSandbox; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Xunit; namespace Microsoft.DurableTask.Client.AzureManaged.Tests; -public class ServerlessActivitiesClientExtensionsTests +public class OnDemandSandboxActivitiesClientExtensionsTests { [Fact] - public async Task AddDurableTaskSchedulerServerlessActivitiesClient_UsesConfiguredDurableTaskClientInvoker() + public async Task AddDurableTaskSchedulerOnDemandSandboxActivitiesClient_UsesConfiguredDurableTaskClientInvoker() { // Arrange - RecordingServerlessLogCallInvoker callInvoker = new(); + RecordingOnDemandSandboxLogCallInvoker callInvoker = new(); ServiceCollection services = new(); services.AddOptions(Options.DefaultName) .Configure(options => options.CallInvoker = callInvoker); - services.AddDurableTaskSchedulerServerlessActivitiesClient(); + services.AddDurableTaskSchedulerOnDemandSandboxActivitiesClient(); using ServiceProvider provider = services.BuildServiceProvider(); - ServerlessActivitiesClient client = provider.GetRequiredService(); + OnDemandSandboxActivitiesClient client = provider.GetRequiredService(); // Act - await client.RemoveServerlessActivityDeclarationAsync("default"); + await client.RemoveOnDemandSandboxActivityDeclarationAsync("default"); // Assert callInvoker.RemoveRequest.Should().NotBeNull(); @@ -35,14 +35,14 @@ public async Task AddDurableTaskSchedulerServerlessActivitiesClient_UsesConfigur } [Fact] - public async Task RemoveServerlessActivityDeclarationAsync_SendsRequest() + public async Task RemoveOnDemandSandboxActivityDeclarationAsync_SendsRequest() { // Arrange - RecordingServerlessLogCallInvoker callInvoker = new(); - ServerlessActivities.ServerlessActivitiesClient client = new(callInvoker); + RecordingOnDemandSandboxLogCallInvoker callInvoker = new(); + OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client = new(callInvoker); // Act - await client.RemoveServerlessActivityDeclarationAsync("default"); + await client.RemoveOnDemandSandboxActivityDeclarationAsync("default"); // Assert callInvoker.RemoveRequest.Should().NotBeNull(); @@ -51,9 +51,9 @@ public async Task RemoveServerlessActivityDeclarationAsync_SendsRequest() callInvoker.UnaryDisposeCount.Should().Be(1); } - sealed class RecordingServerlessLogCallInvoker : CallInvoker + sealed class RecordingOnDemandSandboxLogCallInvoker : CallInvoker { - public RemoveServerlessActivityDeclarationRequest? RemoveRequest { get; private set; } + public RemoveOnDemandSandboxActivityDeclarationRequest? RemoveRequest { get; private set; } public Metadata RemoveHeaders { get; private set; } = []; @@ -74,12 +74,12 @@ public override AsyncUnaryCall AsyncUnaryCall( CallOptions options, TRequest request) { - method.FullName.Should().EndWith("/RemoveServerlessActivityDeclaration"); - this.RemoveRequest = (RemoveServerlessActivityDeclarationRequest)(object)request; + method.FullName.Should().EndWith("/RemoveOnDemandSandboxActivityDeclaration"); + this.RemoveRequest = (RemoveOnDemandSandboxActivityDeclarationRequest)(object)request; this.RemoveHeaders = options.Headers ?? []; return new AsyncUnaryCall( - Task.FromResult((TResponse)(object)new RemoveServerlessActivityDeclarationResult()), + Task.FromResult((TResponse)(object)new RemoveOnDemandSandboxActivityDeclarationResult()), Task.FromResult(new Metadata()), () => new Status(StatusCode.OK, string.Empty), () => new Metadata(), diff --git a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs similarity index 68% rename from test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs rename to test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs index abfda4be..bb3ef825 100644 --- a/test/Extensions/AzureManagedServerless.Tests/ServerlessActivitiesTests.cs +++ b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs @@ -4,9 +4,9 @@ using System.Reflection; using FluentAssertions; using Grpc.Core; -using Microsoft.DurableTask.Protobuf.Serverless; +using Microsoft.DurableTask.Protobuf.OnDemandSandbox; using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.DurableTask.Worker.AzureManaged.Serverless; +using Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; @@ -16,39 +16,39 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Tests; -public class ServerlessActivitiesTests +public class OnDemandSandboxActivitiesTests { const string TaskHub = "testhub"; [Fact] - public void ServerlessDeclarationContract_DoesNotExposeRemovedOptions() + public void OnDemandSandboxDeclarationContract_DoesNotExposeRemovedOptions() { - typeof(ServerlessOptions).GetProperty("LaunchCommand").Should().BeNull(); - typeof(ServerlessOptions).GetProperty("DeclarationRetryMaxAttempts").Should().BeNull(); - typeof(ServerlessOptions).GetProperty("DeclarationRetryDelay").Should().BeNull(); - typeof(ServerlessOptions).GetProperty( + typeof(OnDemandSandboxOptions).GetProperty("LaunchCommand").Should().BeNull(); + typeof(OnDemandSandboxOptions).GetProperty("DeclarationRetryMaxAttempts").Should().BeNull(); + typeof(OnDemandSandboxOptions).GetProperty("DeclarationRetryDelay").Should().BeNull(); + typeof(OnDemandSandboxOptions).GetProperty( "HeartbeatInterval", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); - typeof(ServerlessOptions).GetProperty("WakeupPort").Should().BeNull(); - typeof(ServerlessOptions).GetProperty( + typeof(OnDemandSandboxOptions).GetProperty("WakeupPort").Should().BeNull(); + typeof(OnDemandSandboxOptions).GetProperty( "WorkerRegistrationRetryInitialDelay", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); - typeof(ServerlessOptions).GetProperty( + typeof(OnDemandSandboxOptions).GetProperty( "WorkerRegistrationRetryMaxDelay", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); - typeof(ServerlessOptions).GetProperty( + typeof(OnDemandSandboxOptions).GetProperty( "Mode", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); - typeof(ServerlessActivityDeclaration).GetProperty("LaunchCommand").Should().BeNull(); + typeof(OnDemandSandboxActivityDeclaration).GetProperty("LaunchCommand").Should().BeNull(); } [Fact] - public void ServerlessDeclarationContract_ExposesProfileAddActivityOnly() + public void OnDemandSandboxDeclarationContract_ExposesProfileAddActivityOnly() { // Arrange - Type optionsType = typeof(ServerlessOptions); - Type? activityAttributeType = typeof(ServerlessOptions).Assembly.GetType( - "Microsoft.DurableTask.Worker.AzureManaged.Serverless.ServerlessActivityAttribute"); + Type optionsType = typeof(OnDemandSandboxOptions); + Type? activityAttributeType = typeof(OnDemandSandboxOptions).Assembly.GetType( + "Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox.OnDemandSandboxActivityAttribute"); // Act/Assert optionsType.GetProperty("ActivityNames").Should().BeNull(); @@ -59,10 +59,10 @@ public void ServerlessDeclarationContract_ExposesProfileAddActivityOnly() } [Fact] - public async Task ServerlessActivityDeclarationHostedService_SendsDeclarationPayload() + public async Task OnDemandSandboxActivityDeclarationHostedService_SendsDeclarationPayload() { // Arrange - ServerlessOptions options = new() + OnDemandSandboxOptions options = new() { TaskHub = TaskHub, WorkerProfileId = "profile-a", @@ -77,18 +77,18 @@ public async Task ServerlessActivityDeclarationHostedService_SendsDeclarationPay options.Entrypoint.Add("--"); options.Cmd.Add("dotnet"); options.Cmd.Add("/app/DemoWorker.dll"); - FakeServerlessActivitiesClient client = new(); - ServerlessActivityDeclarationHostedService service = new( + FakeOnDemandSandboxActivitiesClient client = new(); + OnDemandSandboxActivityDeclarationHostedService service = new( client, options, runtimeOptions: null, - NullLogger.Instance); + NullLogger.Instance); // Act await service.StartAsync(CancellationToken.None); // Assert - ServerlessActivityDeclaration declaration = client.Declarations.Should().ContainSingle().Subject; + OnDemandSandboxActivityDeclaration declaration = client.Declarations.Should().ContainSingle().Subject; client.DeclarationTaskHubs.Should().Equal(TaskHub); declaration.WorkerProfileId.Should().Be("profile-a"); declaration.ActivityNames.Should().Equal("RemoteHello"); @@ -102,19 +102,19 @@ public async Task ServerlessActivityDeclarationHostedService_SendsDeclarationPay } [Fact] - public async Task ServerlessActivitiesClientAdapter_SendsTaskHubMetadata() + public async Task OnDemandSandboxActivitiesClientAdapter_SendsTaskHubMetadata() { // Arrange - RecordingServerlessActivitiesCallInvoker callInvoker = new(); - ServerlessActivitiesClientAdapter adapter = new(new ServerlessActivities.ServerlessActivitiesClient(callInvoker)); - ServerlessActivityDeclaration declaration = new() + RecordingOnDemandSandboxActivitiesCallInvoker callInvoker = new(); + OnDemandSandboxActivitiesClientAdapter adapter = new(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)); + OnDemandSandboxActivityDeclaration declaration = new() { WorkerProfileId = "profile-a", - Image = new ServerlessActivityImage + Image = new OnDemandSandboxActivityImage { ImageRef = "example.com/repo/worker:latest", }, - Resources = new ServerlessActivityResources + Resources = new OnDemandSandboxActivityResources { Cpu = "500m", Memory = "1024Mi", @@ -124,8 +124,8 @@ public async Task ServerlessActivitiesClientAdapter_SendsTaskHubMetadata() declaration.ActivityNames.Add("RemoteHello"); // Act - await adapter.DeclareServerlessActivitiesAsync(declaration, TaskHub, CancellationToken.None); - await using IServerlessActivityWorkerSession session = adapter.OpenServerlessActivityWorkerSession( + await adapter.DeclareOnDemandSandboxActivitiesAsync(declaration, TaskHub, CancellationToken.None); + await using IOnDemandSandboxActivityWorkerSession session = adapter.OpenOnDemandSandboxActivityWorkerSession( TaskHub, CancellationToken.None); @@ -135,21 +135,21 @@ public async Task ServerlessActivitiesClientAdapter_SendsTaskHubMetadata() } [Fact] - public async Task ServerlessActivitiesClientAdapter_CanRelyOnChannelTaskHubMetadata() + public async Task OnDemandSandboxActivitiesClientAdapter_CanRelyOnChannelTaskHubMetadata() { // Arrange - RecordingServerlessActivitiesCallInvoker callInvoker = new(); - ServerlessActivitiesClientAdapter adapter = new( - new ServerlessActivities.ServerlessActivitiesClient(callInvoker), + RecordingOnDemandSandboxActivitiesCallInvoker callInvoker = new(); + OnDemandSandboxActivitiesClientAdapter adapter = new( + new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker), attachTaskHubMetadata: false); - ServerlessActivityDeclaration declaration = new() + OnDemandSandboxActivityDeclaration declaration = new() { WorkerProfileId = "profile-a", - Image = new ServerlessActivityImage + Image = new OnDemandSandboxActivityImage { ImageRef = "example.com/repo/worker:latest", }, - Resources = new ServerlessActivityResources + Resources = new OnDemandSandboxActivityResources { Cpu = "500m", Memory = "1024Mi", @@ -159,8 +159,8 @@ public async Task ServerlessActivitiesClientAdapter_CanRelyOnChannelTaskHubMetad declaration.ActivityNames.Add("RemoteHello"); // Act - await adapter.DeclareServerlessActivitiesAsync(declaration, TaskHub, CancellationToken.None); - await using IServerlessActivityWorkerSession session = adapter.OpenServerlessActivityWorkerSession( + await adapter.DeclareOnDemandSandboxActivitiesAsync(declaration, TaskHub, CancellationToken.None); + await using IOnDemandSandboxActivityWorkerSession session = adapter.OpenOnDemandSandboxActivityWorkerSession( TaskHub, CancellationToken.None); @@ -170,46 +170,46 @@ public async Task ServerlessActivitiesClientAdapter_CanRelyOnChannelTaskHubMetad } [Fact] - public async Task ServerlessActivityDeclarationHostedService_OmitsEntrypointAndCmdByDefault() + public async Task OnDemandSandboxActivityDeclarationHostedService_OmitsEntrypointAndCmdByDefault() { // Arrange - ServerlessOptions options = new() + OnDemandSandboxOptions options = new() { TaskHub = TaskHub, ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", }; options.AddActivity("RemoteHello"); - FakeServerlessActivitiesClient client = new(); - ServerlessActivityDeclarationHostedService service = new( + FakeOnDemandSandboxActivitiesClient client = new(); + OnDemandSandboxActivityDeclarationHostedService service = new( client, options, runtimeOptions: null, - NullLogger.Instance); + NullLogger.Instance); // Act await service.StartAsync(CancellationToken.None); // Assert - ServerlessActivityDeclaration declaration = client.Declarations.Should().ContainSingle().Subject; + OnDemandSandboxActivityDeclaration declaration = client.Declarations.Should().ContainSingle().Subject; declaration.Entrypoint.Should().BeEmpty(); declaration.Cmd.Should().BeEmpty(); } [Fact] - public async Task ServerlessActivityDeclarationHostedService_SkipsDeclarationWhenNamesAreEmpty() + public async Task OnDemandSandboxActivityDeclarationHostedService_SkipsDeclarationWhenNamesAreEmpty() { // Arrange - ServerlessOptions options = new() + OnDemandSandboxOptions options = new() { TaskHub = TaskHub, ContainerImage = "example.com/repo/worker:latest", }; - FakeServerlessActivitiesClient client = new(); - ServerlessActivityDeclarationHostedService service = new( + FakeOnDemandSandboxActivitiesClient client = new(); + OnDemandSandboxActivityDeclarationHostedService service = new( client, options, runtimeOptions: null, - NullLogger.Instance); + NullLogger.Instance); // Act await service.StartAsync(CancellationToken.None); @@ -219,21 +219,21 @@ public async Task ServerlessActivityDeclarationHostedService_SkipsDeclarationWhe } [Fact] - public async Task ServerlessActivityDeclarationHostedService_DoesNotRetryTransientFailures() + public async Task OnDemandSandboxActivityDeclarationHostedService_DoesNotRetryTransientFailures() { // Arrange - ServerlessOptions options = new() + OnDemandSandboxOptions options = new() { TaskHub = TaskHub, ContainerImage = "example.com/repo/worker@sha256:abc", }; options.AddActivity("RemoteHello"); - FakeServerlessActivitiesClient client = new() { TransientDeclarationFailures = 1 }; - ServerlessActivityDeclarationHostedService service = new( + FakeOnDemandSandboxActivitiesClient client = new() { TransientDeclarationFailures = 1 }; + OnDemandSandboxActivityDeclarationHostedService service = new( client, options, runtimeOptions: null, - NullLogger.Instance); + NullLogger.Instance); // Act Func action = () => service.StartAsync(CancellationToken.None); @@ -246,7 +246,7 @@ await action.Should().ThrowAsync() } [Fact] - public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWorkerMetadataWithRegisteredActivities() + public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_SendsLiveWorkerMetadataWithRegisteredActivities() { // Arrange string? originalSubstrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); @@ -256,20 +256,20 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWor try { - ServerlessWorkerRuntimeOptions options = new() + OnDemandSandboxWorkerRuntimeOptions options = new() { - Mode = ServerlessMode.ServerlessInclude, + Mode = OnDemandSandboxMode.OnDemandSandboxInclude, TaskHub = TaskHub, WorkerProfileId = "profile-a", MaxConcurrentActivities = 3, HeartbeatInterval = TimeSpan.FromDays(1), }; - FakeServerlessActivitiesClient client = new(); - ServerlessActivityWorkerRegistrationHostedService service = new( + FakeOnDemandSandboxActivitiesClient client = new(); + OnDemandSandboxActivityWorkerRegistrationHostedService service = new( client, options, ["RemoteHello"], - NullLogger.Instance); + NullLogger.Instance); // Act await service.StartAsync(CancellationToken.None); @@ -278,8 +278,8 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWor // Assert client.SessionTaskHubs.Should().Equal(TaskHub); - ServerlessActivityWorkerMessage message = client.Session.Messages.Should().ContainSingle().Subject; - ServerlessActivityWorkerStart start = message.Start; + OnDemandSandboxActivityWorkerMessage message = client.Session.Messages.Should().ContainSingle().Subject; + OnDemandSandboxActivityWorkerStart start = message.Start; start.TaskHub.Should().Be(TaskHub); start.WorkerProfileId.Should().Be("profile-a"); start.MaxActivitiesCount.Should().Be(3); @@ -295,10 +295,10 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_SendsLiveWor } [Fact] - public void ServerlessActivityTracker_TracksInFlightActivityCount() + public void OnDemandSandboxActivityTracker_TracksInFlightActivityCount() { // Arrange - ServerlessActivityTracker activityTracker = new(); + OnDemandSandboxActivityTracker activityTracker = new(); // Act activityTracker.NotifyActivityStarted(); @@ -322,28 +322,28 @@ public void ServerlessActivityTracker_TracksInFlightActivityCount() } [Fact] - public async Task ServerlessActivityWorkerRegistrationHostedService_SendsHeartbeatWithCurrentInFlightCount() + public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_SendsHeartbeatWithCurrentInFlightCount() { // Arrange - ServerlessWorkerRuntimeOptions options = new() + OnDemandSandboxWorkerRuntimeOptions options = new() { - Mode = ServerlessMode.ServerlessInclude, + Mode = OnDemandSandboxMode.OnDemandSandboxInclude, TaskHub = TaskHub, WorkerProfileId = "profile-a", MaxConcurrentActivities = 3, HeartbeatInterval = TimeSpan.FromMilliseconds(10), }; - FakeServerlessActivitiesClient client = new(); - ServerlessActivityTracker activityTracker = new(); + FakeOnDemandSandboxActivitiesClient client = new(); + OnDemandSandboxActivityTracker activityTracker = new(); activityTracker.NotifyActivityStarted(); activityTracker.NotifyActivityStarted(); - ServerlessActivityWorkerRegistrationHostedService service = new( + OnDemandSandboxActivityWorkerRegistrationHostedService service = new( client, options, ["RemoteHello"], - NullLogger.Instance, + NullLogger.Instance, activityTracker: activityTracker); // Act @@ -359,12 +359,12 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_SendsHeartbe } [Fact] - public async Task ServerlessActivityWorkerRegistrationHostedService_ReopensSessionAfterTransientStreamFailure() + public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_ReopensSessionAfterTransientStreamFailure() { // Arrange - ServerlessWorkerRuntimeOptions options = new() + OnDemandSandboxWorkerRuntimeOptions options = new() { - Mode = ServerlessMode.ServerlessInclude, + Mode = OnDemandSandboxMode.OnDemandSandboxInclude, TaskHub = TaskHub, WorkerProfileId = "profile-a", MaxConcurrentActivities = 3, @@ -373,17 +373,17 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_ReopensSessi WorkerRegistrationRetryMaxDelay = TimeSpan.FromMilliseconds(10), }; - FakeServerlessActivityWorkerSession failedSession = new() { ThrowOnWriteAttempt = 2 }; - FakeServerlessActivityWorkerSession recoveredSession = new(); - FakeServerlessActivitiesClient client = new(); + FakeOnDemandSandboxActivityWorkerSession failedSession = new() { ThrowOnWriteAttempt = 2 }; + FakeOnDemandSandboxActivityWorkerSession recoveredSession = new(); + FakeOnDemandSandboxActivitiesClient client = new(); client.QueueSession(failedSession); client.QueueSession(recoveredSession); - ServerlessActivityWorkerRegistrationHostedService service = new( + OnDemandSandboxActivityWorkerRegistrationHostedService service = new( client, options, ["RemoteHello"], - NullLogger.Instance); + NullLogger.Instance); // Act await service.StartAsync(CancellationToken.None); @@ -398,12 +398,12 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_ReopensSessi } [Fact] - public async Task ServerlessActivityWorkerRegistrationHostedService_ReopensSessionAfterTerminalServerFailure() + public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_ReopensSessionAfterTerminalServerFailure() { // Arrange - ServerlessWorkerRuntimeOptions options = new() + OnDemandSandboxWorkerRuntimeOptions options = new() { - Mode = ServerlessMode.ServerlessInclude, + Mode = OnDemandSandboxMode.OnDemandSandboxInclude, TaskHub = TaskHub, WorkerProfileId = "profile-a", MaxConcurrentActivities = 3, @@ -412,17 +412,17 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_ReopensSessi WorkerRegistrationRetryMaxDelay = TimeSpan.FromMilliseconds(10), }; - FakeServerlessActivityWorkerSession failedSession = new(); - FakeServerlessActivityWorkerSession recoveredSession = new(); - FakeServerlessActivitiesClient client = new(); + FakeOnDemandSandboxActivityWorkerSession failedSession = new(); + FakeOnDemandSandboxActivityWorkerSession recoveredSession = new(); + FakeOnDemandSandboxActivitiesClient client = new(); client.QueueSession(failedSession); client.QueueSession(recoveredSession); - ServerlessActivityWorkerRegistrationHostedService service = new( + OnDemandSandboxActivityWorkerRegistrationHostedService service = new( client, options, ["RemoteHello"], - NullLogger.Instance); + NullLogger.Instance); // Act await service.StartAsync(CancellationToken.None); @@ -438,22 +438,22 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_ReopensSessi } [Fact] - public void ServerlessActivityWorkerRegistrationHostedService_ComputeJitteredReconnectDelay_UsesFullJitterWindow() + public void OnDemandSandboxActivityWorkerRegistrationHostedService_ComputeJitteredReconnectDelay_UsesFullJitterWindow() { // Arrange TimeSpan retryDelay = TimeSpan.FromSeconds(10); // Act - TimeSpan zero = ServerlessActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( + TimeSpan zero = OnDemandSandboxActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( TimeSpan.Zero, new DeterministicRandom(0.5)); - TimeSpan low = ServerlessActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( + TimeSpan low = OnDemandSandboxActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( retryDelay, new DeterministicRandom(0.0)); - TimeSpan mid = ServerlessActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( + TimeSpan mid = OnDemandSandboxActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( retryDelay, new DeterministicRandom(0.5)); - TimeSpan high = ServerlessActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( + TimeSpan high = OnDemandSandboxActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( retryDelay, new DeterministicRandom(0.999999)); @@ -466,12 +466,12 @@ public void ServerlessActivityWorkerRegistrationHostedService_ComputeJitteredRec } [Fact] - public async Task ServerlessActivityWorkerRegistrationHostedService_AppliesJitterToReconnectDelay() + public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_AppliesJitterToReconnectDelay() { // Arrange - ServerlessWorkerRuntimeOptions options = new() + OnDemandSandboxWorkerRuntimeOptions options = new() { - Mode = ServerlessMode.ServerlessInclude, + Mode = OnDemandSandboxMode.OnDemandSandboxInclude, TaskHub = TaskHub, WorkerProfileId = "profile-a", MaxConcurrentActivities = 3, @@ -480,17 +480,17 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_AppliesJitte WorkerRegistrationRetryMaxDelay = TimeSpan.FromDays(1), }; - FakeServerlessActivityWorkerSession failedSession = new() { ThrowOnWriteAttempt = 2 }; - FakeServerlessActivityWorkerSession recoveredSession = new(); - FakeServerlessActivitiesClient client = new(); + FakeOnDemandSandboxActivityWorkerSession failedSession = new() { ThrowOnWriteAttempt = 2 }; + FakeOnDemandSandboxActivityWorkerSession recoveredSession = new(); + FakeOnDemandSandboxActivitiesClient client = new(); client.QueueSession(failedSession); client.QueueSession(recoveredSession); - ServerlessActivityWorkerRegistrationHostedService service = new( + OnDemandSandboxActivityWorkerRegistrationHostedService service = new( client, options, ["RemoteHello"], - NullLogger.Instance, + NullLogger.Instance, reconnectJitter: new DeterministicRandom(0.0)); // Act @@ -504,27 +504,27 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_AppliesJitte } [Fact] - public async Task ServerlessActivityWorkerRegistrationHostedService_StopAsync_DoesNotCompleteStreamWhileWriteIsInFlight() + public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_StopAsync_DoesNotCompleteStreamWhileWriteIsInFlight() { // Arrange - ServerlessWorkerRuntimeOptions options = new() + OnDemandSandboxWorkerRuntimeOptions options = new() { - Mode = ServerlessMode.ServerlessInclude, + Mode = OnDemandSandboxMode.OnDemandSandboxInclude, TaskHub = TaskHub, WorkerProfileId = "profile-a", MaxConcurrentActivities = 3, HeartbeatInterval = TimeSpan.FromMilliseconds(10), }; - FakeServerlessActivityWorkerSession session = new() { BlockWriteAttempt = 2 }; - FakeServerlessActivitiesClient client = new(); + FakeOnDemandSandboxActivityWorkerSession session = new() { BlockWriteAttempt = 2 }; + FakeOnDemandSandboxActivitiesClient client = new(); client.QueueSession(session); - ServerlessActivityWorkerRegistrationHostedService service = new( + OnDemandSandboxActivityWorkerRegistrationHostedService service = new( client, options, ["RemoteHello"], - NullLogger.Instance); + NullLogger.Instance); // Act await service.StartAsync(CancellationToken.None); @@ -544,7 +544,7 @@ public async Task ServerlessActivityWorkerRegistrationHostedService_StopAsync_Do } [Fact] - public async Task EnableServerlessActivities_ConfiguresLocalWorkerExclusionFilterFromWorkerProfiles() + public async Task EnableSandboxActivities_ConfiguresLocalWorkerExclusionFilterFromWorkerProfiles() { // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); @@ -558,7 +558,7 @@ public async Task EnableServerlessActivities_ConfiguresLocalWorkerExclusionFilte mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - mockBuilder.Object.EnableServerlessActivities(); + mockBuilder.Object.EnableSandboxActivities(); await using ServiceProvider provider = services.BuildServiceProvider(); DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); @@ -569,7 +569,7 @@ public async Task EnableServerlessActivities_ConfiguresLocalWorkerExclusionFilte } [Fact] - public async Task EnableServerlessActivities_RegistersDeclarationHostedService() + public void EnableSandboxActivities_RegistersDeclarationHostedService() { // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); @@ -580,14 +580,14 @@ public async Task EnableServerlessActivities_RegistersDeclarationHostedService() mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - mockBuilder.Object.EnableServerlessActivities(); + mockBuilder.Object.EnableSandboxActivities(); // Assert services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IHostedService)); } [Fact] - public void EnableServerlessActivities_WhenRunningInServerlessWorker_Throws() + public void EnableSandboxActivities_WhenRunningInOnDemandSandboxWorker_Throws() { // Arrange using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "Sandbox"); @@ -597,29 +597,29 @@ public void EnableServerlessActivities_WhenRunningInServerlessWorker_Throws() mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - Action action = () => mockBuilder.Object.EnableServerlessActivities(); + Action action = () => mockBuilder.Object.EnableSandboxActivities(); // Assert action.Should().Throw() - .WithMessage("EnableServerlessActivities is for declaring serverless activities from the coordinator app. DTS serverless workers should use UseServerlessWorker instead."); + .WithMessage("Activity declaration is for declaring on-demand sandbox activities from the coordinator app. DTS on-demand sandbox workers should use UseSandboxWorker instead."); services.Should().NotContain(descriptor => descriptor.ServiceType == typeof(IHostedService)); } [Fact] - public void ServerlessActivityDeclarationResolver_ResolveDeclarations_UsesWorkerProfileConfigure() + public void OnDemandSandboxActivityDeclarationResolver_ResolveDeclarations_UsesWorkerProfileConfigure() { // Arrange - using EnvironmentVariableScope image = new("DTS_SERVERLESS_ACTIVITY_IMAGE", "example.com/not-used:latest"); - using EnvironmentVariableScope cpu = new("DTS_SERVERLESS_CPU", "2000m"); - using EnvironmentVariableScope memory = new("DTS_SERVERLESS_MEMORY", "4096Mi"); - using EnvironmentVariableScope maxActivities = new("DTS_SERVERLESS_MAX_ACTIVITIES", "99"); + using EnvironmentVariableScope image = new("DTS_ON_DEMAND_SANDBOX_ACTIVITY_IMAGE", "example.com/not-used:latest"); + using EnvironmentVariableScope cpu = new("DTS_ON_DEMAND_SANDBOX_CPU", "2000m"); + using EnvironmentVariableScope memory = new("DTS_ON_DEMAND_SANDBOX_MEMORY", "4096Mi"); + using EnvironmentVariableScope maxActivities = new("DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES", "99"); // Act - ServerlessOptions options = ServerlessActivityDeclarationResolver.ResolveDeclarations(TaskHub) + OnDemandSandboxOptions options = OnDemandSandboxActivityDeclarationResolver.ResolveDeclarations(TaskHub) .Single(options => options.WorkerProfileId == "annotated-profile"); - ServerlessActivityDeclaration declaration = ServerlessActivityConfiguration.BuildDeclaration( + OnDemandSandboxActivityDeclaration declaration = OnDemandSandboxActivityConfiguration.BuildDeclaration( options, - ServerlessActivityConfiguration.ResolveActivityNames(options.ActivityNames)); + OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames)); // Assert declaration.WorkerProfileId.Should().Be("annotated-profile"); @@ -634,13 +634,13 @@ public void ServerlessActivityDeclarationResolver_ResolveDeclarations_UsesWorker } [Fact] - public void ServerlessActivityDeclarationResolver_ResolveDeclaredActivityNames_UsesWorkerProfileConfigure() + public void OnDemandSandboxActivityDeclarationResolver_ResolveDeclaredActivityNames_UsesWorkerProfileConfigure() { // Arrange int before = AnnotatedWorkerProfile.ConfigureCallCount; // Act - string[] activityNames = ServerlessActivityDeclarationResolver.ResolveDeclaredActivityNames(TaskHub); + string[] activityNames = OnDemandSandboxActivityDeclarationResolver.ResolveDeclaredActivityNames(TaskHub); // Assert activityNames.Should().Contain("ConfiguredRemoteHello"); @@ -648,12 +648,12 @@ public void ServerlessActivityDeclarationResolver_ResolveDeclaredActivityNames_U } [Fact] - public async Task UseServerlessWorker_ConfiguresRegisteredActivityWorkerFilter() + public async Task UseSandboxWorker_ConfiguresRegisteredActivityWorkerFilter() { // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); - using EnvironmentVariableScope maxActivities = new("DTS_SERVERLESS_MAX_ACTIVITIES", "3"); + using EnvironmentVariableScope maxActivities = new("DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES", "3"); ServiceCollection services = new(); services.Configure( Options.DefaultName, @@ -663,7 +663,7 @@ public async Task UseServerlessWorker_ConfiguresRegisteredActivityWorkerFilter() mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - mockBuilder.Object.UseServerlessWorker(); + mockBuilder.Object.UseSandboxWorker(); await using ServiceProvider provider = services.BuildServiceProvider(); DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); @@ -680,7 +680,7 @@ public async Task UseServerlessWorker_ConfiguresRegisteredActivityWorkerFilter() } [Fact] - public async Task UseServerlessWorker_WithNoRegisteredActivities_FailsWhenWorkerFiltersAreResolved() + public async Task UseSandboxWorker_WithNoRegisteredActivities_FailsWhenWorkerFiltersAreResolved() { // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); @@ -690,7 +690,7 @@ public async Task UseServerlessWorker_WithNoRegisteredActivities_FailsWhenWorker mockBuilder.Setup(builder => builder.Services).Returns(services); mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); - mockBuilder.Object.UseServerlessWorker(); + mockBuilder.Object.UseSandboxWorker(); await using ServiceProvider provider = services.BuildServiceProvider(); @@ -701,11 +701,11 @@ public async Task UseServerlessWorker_WithNoRegisteredActivities_FailsWhenWorker // Assert act.Should().Throw() - .WithMessage("UseServerlessWorker requires at least one registered activity*"); + .WithMessage("On-demand sandbox workers require at least one registered activity*"); } [Fact] - public async Task UseServerlessWorker_ConfiguresSchedulerWithoutCredential() + public async Task UseSandboxWorker_ConfiguresSchedulerWithoutCredential() { // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); @@ -716,7 +716,7 @@ public async Task UseServerlessWorker_ConfiguresSchedulerWithoutCredential() mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - mockBuilder.Object.UseServerlessWorker(); + mockBuilder.Object.UseSandboxWorker(); await using ServiceProvider provider = services.BuildServiceProvider(); DurableTaskSchedulerWorkerOptions options = provider @@ -731,7 +731,7 @@ public async Task UseServerlessWorker_ConfiguresSchedulerWithoutCredential() } [Fact] - public void UseServerlessWorker_DoesNotRegisterWakeupServerHostedService() + public void UseSandboxWorker_DoesNotRegisterWakeupServerHostedService() { // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); @@ -742,14 +742,14 @@ public void UseServerlessWorker_DoesNotRegisterWakeupServerHostedService() mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - mockBuilder.Object.UseServerlessWorker(); + mockBuilder.Object.UseSandboxWorker(); // Assert services.Count(descriptor => descriptor.ServiceType == typeof(IHostedService)).Should().Be(1); } [Fact] - public void UseServerlessWorker_MissingInjectedEndpoint_Throws() + public void UseSandboxWorker_MissingInjectedEndpoint_Throws() { // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", null); @@ -760,15 +760,15 @@ public void UseServerlessWorker_MissingInjectedEndpoint_Throws() mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - Action action = () => mockBuilder.Object.UseServerlessWorker(); + Action action = () => mockBuilder.Object.UseSandboxWorker(); // Assert action.Should().Throw() - .WithMessage("DTS_ENDPOINT must be injected by DTS for serverless workers."); + .WithMessage("DTS_ENDPOINT must be injected by DTS for on-demand sandbox workers."); } [Fact] - public void UseServerlessWorker_MissingInjectedTaskHub_Throws() + public void UseSandboxWorker_MissingInjectedTaskHub_Throws() { // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); @@ -779,19 +779,19 @@ public void UseServerlessWorker_MissingInjectedTaskHub_Throws() mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - Action action = () => mockBuilder.Object.UseServerlessWorker(); + Action action = () => mockBuilder.Object.UseSandboxWorker(); // Assert action.Should().Throw() - .WithMessage("DTS_TASK_HUB must be injected by DTS for serverless workers."); + .WithMessage("DTS_TASK_HUB must be injected by DTS for on-demand sandbox workers."); } - [ServerlessWorkerProfile("annotated-profile")] - sealed class AnnotatedWorkerProfile : IServerlessWorkerProfile + [OnDemandSandboxWorkerProfile("annotated-profile")] + sealed class AnnotatedWorkerProfile : ISandboxWorkerProfile { public static int ConfigureCallCount { get; private set; } - public void Configure(ServerlessOptions options) + public void Configure(OnDemandSandboxOptions options) { ConfigureCallCount++; options.ContainerImage = "example.com/repo/annotated-worker:latest"; @@ -803,28 +803,28 @@ public void Configure(ServerlessOptions options) } } - sealed class FakeServerlessActivitiesClient : IServerlessActivitiesClient + sealed class FakeOnDemandSandboxActivitiesClient : IOnDemandSandboxActivitiesClient { - readonly Queue queuedSessions = new(); + readonly Queue queuedSessions = new(); public int TransientDeclarationFailures { get; init; } public int DeclarationAttempts { get; private set; } - public List Declarations { get; } = []; + public List Declarations { get; } = []; public List DeclarationTaskHubs { get; } = []; public List SessionTaskHubs { get; } = []; - public List Sessions { get; } = []; + public List Sessions { get; } = []; - public FakeServerlessActivityWorkerSession Session { get; } = new(); + public FakeOnDemandSandboxActivityWorkerSession Session { get; } = new(); - public void QueueSession(FakeServerlessActivityWorkerSession session) => this.queuedSessions.Enqueue(session); + public void QueueSession(FakeOnDemandSandboxActivityWorkerSession session) => this.queuedSessions.Enqueue(session); - public Task DeclareServerlessActivitiesAsync( - ServerlessActivityDeclaration declaration, + public Task DeclareOnDemandSandboxActivitiesAsync( + OnDemandSandboxActivityDeclaration declaration, string taskHub, CancellationToken cancellationToken) { @@ -836,13 +836,13 @@ public Task DeclareServerlessActivitiesAsyn this.DeclarationTaskHubs.Add(taskHub); this.Declarations.Add(declaration.Clone()); - return Task.FromResult(new ServerlessActivityDeclarationResult()); + return Task.FromResult(new OnDemandSandboxActivityDeclarationResult()); } - public IServerlessActivityWorkerSession OpenServerlessActivityWorkerSession(string taskHub, CancellationToken cancellationToken) + public IOnDemandSandboxActivityWorkerSession OpenOnDemandSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken) { this.SessionTaskHubs.Add(taskHub); - FakeServerlessActivityWorkerSession session = this.queuedSessions.Count > 0 + FakeOnDemandSandboxActivityWorkerSession session = this.queuedSessions.Count > 0 ? this.queuedSessions.Dequeue() : this.Session; this.Sessions.Add(session); @@ -850,7 +850,7 @@ public IServerlessActivityWorkerSession OpenServerlessActivityWorkerSession(stri } } - sealed class RecordingServerlessActivitiesCallInvoker : CallInvoker + sealed class RecordingOnDemandSandboxActivitiesCallInvoker : CallInvoker { public Metadata DeclarationHeaders { get; private set; } = []; @@ -871,11 +871,11 @@ public override AsyncUnaryCall AsyncUnaryCall( CallOptions options, TRequest request) { - method.FullName.Should().EndWith("/DeclareServerlessActivities"); + method.FullName.Should().EndWith("/DeclareOnDemandSandboxActivities"); this.DeclarationHeaders = options.Headers ?? []; return new AsyncUnaryCall( - Task.FromResult((TResponse)(object)new ServerlessActivityDeclarationResult()), + Task.FromResult((TResponse)(object)new OnDemandSandboxActivityDeclarationResult()), Task.FromResult(new Metadata()), () => new Status(StatusCode.OK, string.Empty), () => [], @@ -896,12 +896,12 @@ public override AsyncClientStreamingCall AsyncClientStreami string? host, CallOptions options) { - method.FullName.Should().EndWith("/ConnectServerlessActivityWorker"); + method.FullName.Should().EndWith("/ConnectOnDemandSandboxActivityWorker"); this.WorkerSessionHeaders = options.Headers ?? []; return new AsyncClientStreamingCall( new RecordingClientStreamWriter(), - Task.FromResult((TResponse)(object)new ServerlessActivityWorkerSessionResult()), + Task.FromResult((TResponse)(object)new OnDemandSandboxActivityWorkerSessionResult()), Task.FromResult(new Metadata()), () => new Status(StatusCode.OK, string.Empty), () => [], @@ -926,10 +926,10 @@ sealed class RecordingClientStreamWriter : IClientStreamWriter public Task CompleteAsync() => Task.CompletedTask; } - sealed class FakeServerlessActivityWorkerSession : IServerlessActivityWorkerSession + sealed class FakeOnDemandSandboxActivityWorkerSession : IOnDemandSandboxActivityWorkerSession { readonly object sync = new(); - readonly TaskCompletionSource completion = + readonly TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); readonly TaskCompletionSource blockedWriteStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -938,7 +938,7 @@ sealed class FakeServerlessActivityWorkerSession : IServerlessActivityWorkerSess int writeAttempts; int activeWrites; - public List Messages { get; } = []; + public List Messages { get; } = []; public int? ThrowOnWriteAttempt { get; init; } @@ -962,7 +962,7 @@ public Task WaitForCompleteAsync() public void ReleaseBlockedWrite() => this.releaseBlockedWrite.TrySetResult(); - public async Task WaitForMessageAsync(Func predicate) + public async Task WaitForMessageAsync(Func predicate) { using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5)); while (!timeout.IsCancellationRequested) @@ -978,10 +978,10 @@ public async Task WaitForMessageAsync(Func WaitForCompletionAsync() => this.completion.Task; + public Task WaitForCompletionAsync() => this.completion.Task; public async Task CompleteAsync() { @@ -1014,13 +1014,13 @@ public async Task CompleteAsync() this.CompleteCalledWhileWriteActive = this.activeWrites > 0; } - this.completion.TrySetResult(new ServerlessActivityWorkerSessionResult { Accepted = true }); + this.completion.TrySetResult(new OnDemandSandboxActivityWorkerSessionResult { Accepted = true }); await this.completion.Task.ConfigureAwait(false); } public ValueTask DisposeAsync() => default; - async Task WriteMessageCoreAsync(ServerlessActivityWorkerMessage message, bool blockWrite) + async Task WriteMessageCoreAsync(OnDemandSandboxActivityWorkerMessage message, bool blockWrite) { try { From a4b2aca05a350a8fead8ac80f9d5f0e20271abdd Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 2 Jun 2026 06:08:35 -0700 Subject: [PATCH 45/81] Validate sandbox CPU/memory and add logging Add SDK-side validation for on-demand sandbox CPU and memory quantities and improve worker robustness. Changes include: - Validate CPU and memory formats in OnDemandSandboxActivityConfiguration (NormalizeCpu/NormalizeMemory, TryParseCpuMillicores, TryParseMemoryMiB) and throw clear InvalidOperationException for invalid or non-positive quantities. - Update OnDemandSandboxOptions XML docs to show supported CPU/memory formats (e.g. 500m, 2, 0.5; 256Mi, 1Gi, 2048). - Add unit tests covering accepted and rejected resource quantities and a helper CreateDeclarationOptions used in tests. - Add multiple debug LoggerMessage entries and emit those logs in OnDemandSandboxActivityWorkerRegistrationHostedService to ignore expected shutdown/cancellation failures and surface registration/heartbeat failures more clearly. - Refactor registration retry/error handling: extract HandleRetriableRegistrationFailureAsync, log failures, delay/retry on retriable errors, and stop application on non-retriable errors. - Filter out empty activity filter names when merging activity filters and simplify Coalesce implementation to use LINQ. - Update CHANGELOG to mention the new SDK-side validation. These changes ensure resource quantities are validated early and improve resiliency and observability during worker registration, heartbeat, and shutdown scenarios. --- CHANGELOG.md | 1 + ...chedulerOnDemandSandboxWorkerExtensions.cs | 7 +- .../Worker/OnDemandSandbox/Logs.cs | 36 +++++++ .../OnDemandSandboxActivityConfiguration.cs | 99 ++++++++++++++++--- ...ActivityWorkerRegistrationHostedService.cs | 72 +++++++++++--- .../OnDemandSandbox/OnDemandSandboxOptions.cs | 4 +- .../OnDemandSandboxActivitiesTests.cs | 65 ++++++++++++ 7 files changed, 255 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42edcb22..91c32a51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Updated private preview on-demand sandbox worker profile declarations to use `OnDemandSandboxOptions.AddActivity(...)`, and updated the on-demand sandbox sample to share activity name constants between the main app and remote worker. +- Added SDK-side validation for private preview on-demand sandbox CPU and memory resource quantities. ## v1.24.2 - Bump DI.Abstractions and Bcl.AsyncInterfaces to 9.0.1 ([#3433](https://github.com/microsoft/durabletask-dotnet/pull/3433)) (#723) diff --git a/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs b/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs index babe7c52..c15add48 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs @@ -252,12 +252,9 @@ static DurableTaskWorkerWorkItemFilters.ActivityFilter[] MergeActivityFilters( IEnumerable activityNames) { Dictionary merged = new(StringComparer.OrdinalIgnoreCase); - foreach (DurableTaskWorkerWorkItemFilters.ActivityFilter filter in existingFilters) + foreach (DurableTaskWorkerWorkItemFilters.ActivityFilter filter in existingFilters.Where(static filter => !string.IsNullOrWhiteSpace(filter.Name))) { - if (!string.IsNullOrWhiteSpace(filter.Name)) - { - merged[filter.Name] = filter; - } + merged[filter.Name] = filter; } foreach (string activityName in activityNames) diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/Logs.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/Logs.cs index 28d66ce8..487bdaeb 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/Logs.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/Logs.cs @@ -47,4 +47,40 @@ public static partial void OnDemandSandboxActivityWorkerRegistered( Level = LogLevel.Error, Message = "On-demand sandbox activity worker registration stream failed hub={Hub}")] public static partial void OnDemandSandboxActivityWorkerRegistrationFailed(ILogger logger, Exception exception, string hub); + + [LoggerMessage( + EventId = 8, + Level = LogLevel.Debug, + Message = "Ignoring on-demand sandbox worker session completion failure during shutdown.")] + public static partial void OnDemandSandboxWorkerSessionCompletionFailureIgnored(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 9, + Level = LogLevel.Debug, + Message = "Ignoring on-demand sandbox worker registration pump cancellation during shutdown.")] + public static partial void OnDemandSandboxWorkerRegistrationPumpCancellationIgnored(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 10, + Level = LogLevel.Debug, + Message = "Ignoring on-demand sandbox worker registration pump failure during shutdown.")] + public static partial void OnDemandSandboxWorkerRegistrationPumpFailureIgnored(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 11, + Level = LogLevel.Debug, + Message = "Ignoring on-demand sandbox worker session dispose failure during shutdown.")] + public static partial void OnDemandSandboxWorkerSessionDisposeFailureIgnored(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 12, + Level = LogLevel.Debug, + Message = "Ignoring on-demand sandbox heartbeat pump cancellation after registration session completion.")] + public static partial void OnDemandSandboxHeartbeatPumpCancellationIgnored(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 13, + Level = LogLevel.Debug, + Message = "Ignoring on-demand sandbox heartbeat pump failure after registration session completion.")] + public static partial void OnDemandSandboxHeartbeatPumpFailureIgnored(ILogger logger, Exception exception); } diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs index 7f5a4d66..a34321d9 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Globalization; using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; @@ -144,8 +145,8 @@ static Proto.OnDemandSandboxActivityImage BuildImage(OnDemandSandboxOptions opti static Proto.OnDemandSandboxActivityResources BuildResources(OnDemandSandboxOptions options) { - string cpu = NormalizeRequired(options.Cpu, "On-demand sandbox activity declaration requires CPU resources."); - string memory = NormalizeRequired(options.Memory, "On-demand sandbox activity declaration requires memory resources."); + string cpu = NormalizeCpu(options.Cpu); + string memory = NormalizeMemory(options.Memory); return new Proto.OnDemandSandboxActivityResources { @@ -195,6 +196,87 @@ static string NormalizeRequired(string value, string errorMessage) return value.Trim(); } + static string NormalizeCpu(string value) + { + string normalized = NormalizeRequired(value, "On-demand sandbox activity declaration requires CPU resources."); + if (TryParseCpuMillicores(normalized) is not { } milliCpu || milliCpu <= 0) + { + throw new InvalidOperationException( + "On-demand sandbox activity CPU resources must be a positive Kubernetes-style CPU quantity. " + + "Use formats like '500m', '2', or '0.5'."); + } + + return normalized; + } + + static string NormalizeMemory(string value) + { + string normalized = NormalizeRequired(value, "On-demand sandbox activity declaration requires memory resources."); + if (TryParseMemoryMiB(normalized) is not { } memoryMiB || memoryMiB <= 0) + { + throw new InvalidOperationException( + "On-demand sandbox activity memory resources must be a positive Kubernetes-style memory quantity. " + + "Use formats like '256Mi', '1Gi', or '2048'."); + } + + return normalized; + } + + static long? TryParseCpuMillicores(string value) + { + if (value.EndsWith('m') || value.EndsWith('M')) + { + return decimal.TryParse( + value[..^1], + NumberStyles.Number, + CultureInfo.InvariantCulture, + out decimal milliCpu) + ? (long)milliCpu + : null; + } + + return decimal.TryParse( + value, + NumberStyles.Number, + CultureInfo.InvariantCulture, + out decimal cores) + ? (long)(cores * 1000) + : null; + } + + static long? TryParseMemoryMiB(string value) + { + if (value.EndsWith("Gi", StringComparison.OrdinalIgnoreCase)) + { + return decimal.TryParse( + value[..^2], + NumberStyles.Number, + CultureInfo.InvariantCulture, + out decimal gib) + ? (long)(gib * 1024) + : null; + } + + if (value.EndsWith("Mi", StringComparison.OrdinalIgnoreCase)) + { + return decimal.TryParse( + value[..^2], + NumberStyles.Number, + CultureInfo.InvariantCulture, + out decimal mib) + ? (long)mib + : null; + } + + return decimal.TryParse( + value, + NumberStyles.Number, + CultureInfo.InvariantCulture, + out decimal parsed) + ? (long)parsed + : null; + } + static string[] NormalizeOptionalStrings(IEnumerable values) { return values @@ -221,14 +303,9 @@ static string[] NormalizeOptionalStrings(IEnumerable values) static string? Coalesce(params string?[] values) { - foreach (string? value in values) - { - if (!string.IsNullOrWhiteSpace(value)) - { - return value.Trim(); - } - } - - return null; + return values + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value!.Trim()) + .FirstOrDefault(); } } diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs index f9fe1b2d..ea56755a 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs @@ -108,6 +108,7 @@ public async Task StopAsync(CancellationToken cancellationToken) } catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or RpcException) { + Logs.OnDemandSandboxWorkerSessionCompletionFailureIgnored(this.logger, ex); } } @@ -117,11 +118,13 @@ public async Task StopAsync(CancellationToken cancellationToken) { await localPump.WaitAsync(cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) { + Logs.OnDemandSandboxWorkerRegistrationPumpCancellationIgnored(this.logger, ex); } catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or RpcException) { + Logs.OnDemandSandboxWorkerRegistrationPumpFailureIgnored(this.logger, ex); } } @@ -167,7 +170,9 @@ internal static TimeSpan ComputeJitteredReconnectDelay(TimeSpan retryDelay, Rand return TimeSpan.FromTicks(jitteredTicks); } - static async ValueTask DisposeSessionAsync(IOnDemandSandboxActivityWorkerSession registrationSession) + static async ValueTask DisposeSessionAsync( + IOnDemandSandboxActivityWorkerSession registrationSession, + ILogger logger) { try { @@ -175,6 +180,7 @@ static async ValueTask DisposeSessionAsync(IOnDemandSandboxActivityWorkerSession } catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or RpcException) { + Logs.OnDemandSandboxWorkerSessionDisposeFailureIgnored(logger, ex); } } @@ -215,29 +221,61 @@ async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancell { break; } - catch (Exception ex) when (!IsRetriableRegistrationFailure(ex)) + catch (RpcException ex) when (IsRetriableRegistrationFailure(ex)) { - Logs.OnDemandSandboxActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); - this.lifetime?.StopApplication(); - break; + retryDelay = await this.HandleRetriableRegistrationFailureAsync( + ex, + retryDelay, + cancellationToken).ConfigureAwait(false); + } + catch (IOException ex) when (IsRetriableRegistrationFailure(ex)) + { + retryDelay = await this.HandleRetriableRegistrationFailureAsync( + ex, + retryDelay, + cancellationToken).ConfigureAwait(false); + } + catch (ObjectDisposedException ex) when (IsRetriableRegistrationFailure(ex)) + { + retryDelay = await this.HandleRetriableRegistrationFailureAsync( + ex, + retryDelay, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (IsRetriableRegistrationFailure(ex)) + { + retryDelay = await this.HandleRetriableRegistrationFailureAsync( + ex, + retryDelay, + cancellationToken).ConfigureAwait(false); } catch (Exception ex) { Logs.OnDemandSandboxActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); - await this.DelayBeforeReconnectAsync(retryDelay, cancellationToken).ConfigureAwait(false); - retryDelay = this.GetNextRetryDelay(retryDelay); + this.lifetime?.StopApplication(); + break; } finally { if (registrationSession is not null) { this.ClearCurrentSession(registrationSession); - await DisposeSessionAsync(registrationSession).ConfigureAwait(false); + await DisposeSessionAsync(registrationSession, this.logger).ConfigureAwait(false); } } } } + async Task HandleRetriableRegistrationFailureAsync( + Exception exception, + TimeSpan retryDelay, + CancellationToken cancellationToken) + { + Logs.OnDemandSandboxActivityWorkerRegistrationFailed(this.logger, exception, this.options.TaskHub); + await this.DelayBeforeReconnectAsync(retryDelay, cancellationToken).ConfigureAwait(false); + return this.GetNextRetryDelay(retryDelay); + } + async Task RunRegistrationSessionAsync( IOnDemandSandboxActivityWorkerSession registrationSession, CancellationToken cancellationToken) @@ -254,12 +292,24 @@ async Task RunRegistrationSessionAsync( { await heartbeatTask.ConfigureAwait(false); } - catch (OperationCanceledException) when (heartbeatCts.IsCancellationRequested) + catch (OperationCanceledException ex) when (heartbeatCts.IsCancellationRequested) { + Logs.OnDemandSandboxHeartbeatPumpCancellationIgnored(this.logger, ex); + } + catch (RpcException ex) + { + // The server response is authoritative once the response task wins the race. + Logs.OnDemandSandboxHeartbeatPumpFailureIgnored(this.logger, ex); + } + catch (IOException ex) + { + // The server response is authoritative once the response task wins the race. + Logs.OnDemandSandboxHeartbeatPumpFailureIgnored(this.logger, ex); } - catch (Exception) + catch (ObjectDisposedException ex) { // The server response is authoritative once the response task wins the race. + Logs.OnDemandSandboxHeartbeatPumpFailureIgnored(this.logger, ex); } await completionTask.ConfigureAwait(false); diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs index bd14c968..b519fbee 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs @@ -51,12 +51,12 @@ public sealed class OnDemandSandboxOptions public string? ImageDigest { get; set; } /// - /// Gets or sets the CPU quantity declared for each sandbox. + /// Gets or sets the CPU quantity declared for each sandbox. Supported formats include 500m, 2, and 0.5. /// public string Cpu { get; set; } = "1000m"; /// - /// Gets or sets the memory quantity declared for each sandbox. + /// Gets or sets the memory quantity declared for each sandbox. Supported formats include 256Mi, 1Gi, and 2048. /// public string Memory { get; set; } = "2048Mi"; diff --git a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs index bb3ef825..fb9a9275 100644 --- a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs +++ b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs @@ -101,6 +101,56 @@ public async Task OnDemandSandboxActivityDeclarationHostedService_SendsDeclarati declaration.MaxConcurrentActivities.Should().Be(7); } + [Theory] + [InlineData("500m", "1024Mi")] + [InlineData("0.5", "1Gi")] + [InlineData("2", "2048")] + public void OnDemandSandboxActivityConfiguration_BuildDeclaration_AcceptsAdcResourceQuantities( + string cpu, + string memory) + { + // Arrange + OnDemandSandboxOptions options = CreateDeclarationOptions(); + options.Cpu = cpu; + options.Memory = memory; + + // Act + OnDemandSandboxActivityDeclaration declaration = OnDemandSandboxActivityConfiguration.BuildDeclaration( + options, + OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames)); + + // Assert + declaration.Resources.Cpu.Should().Be(cpu); + declaration.Resources.Memory.Should().Be(memory); + } + + [Theory] + [InlineData("0", "1024Mi", "CPU")] + [InlineData("0m", "1024Mi", "CPU")] + [InlineData("500Mi", "1024Mi", "CPU")] + [InlineData("500m", "0", "memory")] + [InlineData("500m", "0Mi", "memory")] + [InlineData("500m", "500m", "memory")] + public void OnDemandSandboxActivityConfiguration_BuildDeclaration_RejectsInvalidAdcResourceQuantities( + string cpu, + string memory, + string expectedMessage) + { + // Arrange + OnDemandSandboxOptions options = CreateDeclarationOptions(); + options.Cpu = cpu; + options.Memory = memory; + + // Act + Action action = () => OnDemandSandboxActivityConfiguration.BuildDeclaration( + options, + OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames)); + + // Assert + action.Should().Throw() + .WithMessage($"*{expectedMessage}*"); + } + [Fact] public async Task OnDemandSandboxActivitiesClientAdapter_SendsTaskHubMetadata() { @@ -786,6 +836,21 @@ public void UseSandboxWorker_MissingInjectedTaskHub_Throws() .WithMessage("DTS_TASK_HUB must be injected by DTS for on-demand sandbox workers."); } + static OnDemandSandboxOptions CreateDeclarationOptions() + { + OnDemandSandboxOptions options = new() + { + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", + Cpu = "500m", + Memory = "1024Mi", + MaxConcurrentActivities = 7, + }; + options.AddActivity("RemoteHello"); + return options; + } + [OnDemandSandboxWorkerProfile("annotated-profile")] sealed class AnnotatedWorkerProfile : ISandboxWorkerProfile { From 57710081fb41068156e14343331d817b9e4f8479 Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 2 Jun 2026 13:26:07 -0700 Subject: [PATCH 46/81] Remove 'Accepted' from session result proto Removed the obsolete 'accepted' boolean from OnDemandSandboxActivityWorkerSessionResult in the .proto and updated tests to stop setting that property. Aligns test code with the updated proto; regenerate gRPC/protobuf-generated code if needed. --- src/Grpc/on_demand_sandbox_activities_service.proto | 1 - .../OnDemandSandboxActivitiesTests.cs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Grpc/on_demand_sandbox_activities_service.proto b/src/Grpc/on_demand_sandbox_activities_service.proto index 95b1d53f..38afc737 100644 --- a/src/Grpc/on_demand_sandbox_activities_service.proto +++ b/src/Grpc/on_demand_sandbox_activities_service.proto @@ -53,7 +53,6 @@ message OnDemandSandboxActivityWorkerHeartbeat { } message OnDemandSandboxActivityWorkerSessionResult { - bool accepted = 1; string message = 2; } diff --git a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs index fb9a9275..2f214c92 100644 --- a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs +++ b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs @@ -1079,7 +1079,7 @@ public async Task CompleteAsync() this.CompleteCalledWhileWriteActive = this.activeWrites > 0; } - this.completion.TrySetResult(new OnDemandSandboxActivityWorkerSessionResult { Accepted = true }); + this.completion.TrySetResult(new OnDemandSandboxActivityWorkerSessionResult()); await this.completion.Task.ConfigureAwait(false); } From 9d3289863787a48ca0a27186747783a756d2b454 Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 2 Jun 2026 17:33:29 -0700 Subject: [PATCH 47/81] Add managed identity support for on-demand sandbox Introduce support for authenticating to the DTS scheduler using a user-assigned managed identity. Adds SchedulerManagedIdentityClientId to OnDemandSandboxOptions and includes it in the OnDemandSandboxActivityDeclaration proto. Configure the worker to create a ManagedIdentityCredential when DTS_AUTHENTICATION=ManagedIdentity (reading DTS_UMI_CLIENT_ID), with helpers to validate env vars. Update tests to assert the new field and behavior, and adjust activity configuration accordingly. --- ...chedulerOnDemandSandboxWorkerExtensions.cs | 13 ++++ .../OnDemandSandboxActivityConfiguration.cs | 9 ++- .../OnDemandSandbox/OnDemandSandboxOptions.cs | 5 ++ ...on_demand_sandbox_activities_service.proto | 1 + .../OnDemandSandboxActivitiesTests.cs | 78 +++++++++++++++++++ 5 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs b/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs index c15add48..384eed0f 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Linq; +using Azure.Core; +using Azure.Identity; using Grpc.Net.Client; using Microsoft.DurableTask.Protobuf.OnDemandSandbox; using Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; @@ -206,9 +208,20 @@ static void ConfigureDurableTaskSchedulerFromEnvironment(IDurableTaskWorkerBuild options.EndpointAddress = endpoint; options.TaskHubName = taskHub; options.AllowInsecureCredentials = true; + if (UsesManagedIdentityAuthentication(Environment.GetEnvironmentVariable("DTS_AUTHENTICATION"))) + { + options.Credential = CreateManagedIdentityCredential(); + options.AllowInsecureCredentials = false; + } }); } + static bool UsesManagedIdentityAuthentication(string? authentication) => + string.Equals(authentication, "ManagedIdentity", StringComparison.OrdinalIgnoreCase); + + static TokenCredential CreateManagedIdentityCredential() => + new ManagedIdentityCredential(ManagedIdentityId.FromUserAssignedClientId(GetRequiredEnvironmentVariable("DTS_UMI_CLIENT_ID"))); + static string GetRequiredEnvironmentVariable(string name) { string? value = Environment.GetEnvironmentVariable(name); diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs index a34321d9..d36db486 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs @@ -50,12 +50,17 @@ public static Proto.OnDemandSandboxActivityDeclaration BuildDeclaration(OnDemand throw new InvalidOperationException("On-demand sandbox activity max concurrent activities must be greater than zero."); } + string schedulerManagedIdentityClientId = NormalizeRequired( + options.SchedulerManagedIdentityClientId ?? string.Empty, + "On-demand sandbox activity declaration requires the managed identity client ID workers use to connect to the DTS scheduler."); + Proto.OnDemandSandboxActivityDeclaration declaration = new() { WorkerProfileId = workerProfileId, Image = BuildImage(options), Resources = BuildResources(options), MaxConcurrentActivities = options.MaxConcurrentActivities, + SchedulerManagedIdentityClientId = schedulerManagedIdentityClientId, }; declaration.ActivityNames.AddRange(activityNames); @@ -137,10 +142,12 @@ static Proto.OnDemandSandboxActivityImage BuildImage(OnDemandSandboxOptions opti throw new InvalidOperationException("On-demand sandbox activity image metadata requires a container image reference."); } - return new Proto.OnDemandSandboxActivityImage + Proto.OnDemandSandboxActivityImage image = new() { ImageRef = imageRef, }; + + return image; } static Proto.OnDemandSandboxActivityResources BuildResources(OnDemandSandboxOptions options) diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs index b519fbee..a27476f6 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs @@ -50,6 +50,11 @@ public sealed class OnDemandSandboxOptions /// public string? ImageDigest { get; set; } + /// + /// Gets or sets the user-assigned managed identity client ID workers use to authenticate to the DTS scheduler. + /// + public string? SchedulerManagedIdentityClientId { get; set; } + /// /// Gets or sets the CPU quantity declared for each sandbox. Supported formats include 500m, 2, and 0.5. /// diff --git a/src/Grpc/on_demand_sandbox_activities_service.proto b/src/Grpc/on_demand_sandbox_activities_service.proto index 38afc737..3c18b756 100644 --- a/src/Grpc/on_demand_sandbox_activities_service.proto +++ b/src/Grpc/on_demand_sandbox_activities_service.proto @@ -65,6 +65,7 @@ message OnDemandSandboxActivityDeclaration { OnDemandSandboxActivityResources resources = 7; repeated string entrypoint = 8; repeated string cmd = 9; + string scheduler_managed_identity_client_id = 10; } message OnDemandSandboxActivityImage { diff --git a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs index 2f214c92..e71fff31 100644 --- a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs +++ b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Reflection; +using Azure.Identity; using FluentAssertions; using Grpc.Core; using Microsoft.DurableTask.Protobuf.OnDemandSandbox; @@ -67,6 +68,7 @@ public async Task OnDemandSandboxActivityDeclarationHostedService_SendsDeclarati TaskHub = TaskHub, WorkerProfileId = "profile-a", ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", + SchedulerManagedIdentityClientId = "scheduler-client-id", Cpu = "500m", Memory = "1024Mi", MaxConcurrentActivities = 7, @@ -93,6 +95,7 @@ public async Task OnDemandSandboxActivityDeclarationHostedService_SendsDeclarati declaration.WorkerProfileId.Should().Be("profile-a"); declaration.ActivityNames.Should().Equal("RemoteHello"); declaration.Image.ImageRef.Should().Be("mcr.microsoft.com/durabletask/demo-worker:1.0"); + declaration.SchedulerManagedIdentityClientId.Should().Be("scheduler-client-id"); declaration.Resources.Cpu.Should().Be("500m"); declaration.Resources.Memory.Should().Be("1024Mi"); declaration.EnvironmentVariables.Should().ContainKey("CUSTOM_SETTING").WhoseValue.Should().Be("enabled"); @@ -151,6 +154,23 @@ public void OnDemandSandboxActivityConfiguration_BuildDeclaration_RejectsInvalid .WithMessage($"*{expectedMessage}*"); } + [Fact] + public void OnDemandSandboxActivityConfiguration_BuildDeclaration_RequiresSchedulerManagedIdentityClientId() + { + // Arrange + OnDemandSandboxOptions options = CreateDeclarationOptions(); + options.SchedulerManagedIdentityClientId = " "; + + // Act + Action action = () => OnDemandSandboxActivityConfiguration.BuildDeclaration( + options, + OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames)); + + // Assert + action.Should().Throw() + .WithMessage("*managed identity client ID*"); + } + [Fact] public async Task OnDemandSandboxActivitiesClientAdapter_SendsTaskHubMetadata() { @@ -227,6 +247,7 @@ public async Task OnDemandSandboxActivityDeclarationHostedService_OmitsEntrypoin { TaskHub = TaskHub, ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", + SchedulerManagedIdentityClientId = "scheduler-client-id", }; options.AddActivity("RemoteHello"); FakeOnDemandSandboxActivitiesClient client = new(); @@ -276,6 +297,7 @@ public async Task OnDemandSandboxActivityDeclarationHostedService_DoesNotRetryTr { TaskHub = TaskHub, ContainerImage = "example.com/repo/worker@sha256:abc", + SchedulerManagedIdentityClientId = "scheduler-client-id", }; options.AddActivity("RemoteHello"); FakeOnDemandSandboxActivitiesClient client = new() { TransientDeclarationFailures = 1 }; @@ -675,6 +697,7 @@ public void OnDemandSandboxActivityDeclarationResolver_ResolveDeclarations_UsesW declaration.WorkerProfileId.Should().Be("annotated-profile"); declaration.ActivityNames.Should().Equal("ConfiguredRemoteHello"); declaration.Image.ImageRef.Should().Be("example.com/repo/annotated-worker:latest"); + declaration.SchedulerManagedIdentityClientId.Should().Be("scheduler-client-id"); declaration.Resources.Cpu.Should().Be("500m"); declaration.Resources.Memory.Should().Be("1024Mi"); declaration.MaxConcurrentActivities.Should().Be(4); @@ -780,6 +803,59 @@ public async Task UseSandboxWorker_ConfiguresSchedulerWithoutCredential() options.AllowInsecureCredentials.Should().BeTrue(); } + [Fact] + public async Task UseSandboxWorker_WithManagedIdentityAuth_ConfiguresSchedulerCredential() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); + using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + // Act + mockBuilder.Object.UseSandboxWorker(); + + await using ServiceProvider provider = services.BuildServiceProvider(); + DurableTaskSchedulerWorkerOptions options = provider + .GetRequiredService>() + .Get(Options.DefaultName); + + // Assert + options.EndpointAddress.Should().Be("https://example.scheduler"); + options.TaskHubName.Should().Be(TaskHub); + options.Credential.Should().BeOfType(); + options.AllowInsecureCredentials.Should().BeFalse(); + } + + [Fact] + public async Task UseSandboxWorker_WithManagedIdentityAuthAndMissingClientId_Throws() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); + using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", null); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + // Act + mockBuilder.Object.UseSandboxWorker(); + await using ServiceProvider provider = services.BuildServiceProvider(); + Action getOptions = () => provider + .GetRequiredService>() + .Get(Options.DefaultName); + + // Assert + getOptions.Should().Throw() + .WithMessage("*DTS_UMI_CLIENT_ID*"); + } + [Fact] public void UseSandboxWorker_DoesNotRegisterWakeupServerHostedService() { @@ -843,6 +919,7 @@ static OnDemandSandboxOptions CreateDeclarationOptions() TaskHub = TaskHub, WorkerProfileId = "profile-a", ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", + SchedulerManagedIdentityClientId = "scheduler-client-id", Cpu = "500m", Memory = "1024Mi", MaxConcurrentActivities = 7, @@ -860,6 +937,7 @@ public void Configure(OnDemandSandboxOptions options) { ConfigureCallCount++; options.ContainerImage = "example.com/repo/annotated-worker:latest"; + options.SchedulerManagedIdentityClientId = "scheduler-client-id"; options.Cpu = "500m"; options.Memory = "1024Mi"; options.MaxConcurrentActivities = 4; From f782197900cb91ce9bab2090dcca42539c417827 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 3 Jun 2026 14:55:52 -0700 Subject: [PATCH 48/81] Add image-pull managed identity support Introduce ImagePullManagedIdentityClientId to OnDemandSandboxOptions and propagate it into the activity declaration (new managed_identity_client_id proto field). Validate the value when building the declaration and update the sample WorkerProfiles to require the environment variable (helper GetRequiredEnvironmentVariable added). Tests updated to include the new property and a unit test ensuring the image-pull managed identity client ID is required. --- .../main-app/WorkerProfiles.cs | 13 ++++++++++ .../OnDemandSandboxActivityConfiguration.cs | 3 +++ .../OnDemandSandbox/OnDemandSandboxOptions.cs | 5 ++++ ...on_demand_sandbox_activities_service.proto | 1 + .../OnDemandSandboxActivitiesTests.cs | 24 +++++++++++++++++++ 5 files changed, 46 insertions(+) diff --git a/samples/on-demand-sandbox/main-app/WorkerProfiles.cs b/samples/on-demand-sandbox/main-app/WorkerProfiles.cs index 03608f6c..d6aa0a3a 100644 --- a/samples/on-demand-sandbox/main-app/WorkerProfiles.cs +++ b/samples/on-demand-sandbox/main-app/WorkerProfiles.cs @@ -12,6 +12,8 @@ internal sealed class DefaultSandboxWorkerProfile : ISandboxWorkerProfile public void Configure(OnDemandSandboxOptions options) { options.ContainerImage = Environment.GetEnvironmentVariable("DTS_ON_DEMAND_SANDBOX_CONTAINER_IMAGE") ?? "on-demand-sandbox-remote-worker:local"; + options.ImagePullManagedIdentityClientId = GetRequiredEnvironmentVariable("DTS_ON_DEMAND_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID"); + options.SchedulerManagedIdentityClientId = GetRequiredEnvironmentVariable("DTS_ON_DEMAND_SANDBOX_SCHEDULER_UMI_CLIENT_ID"); options.Cpu = "1000m"; options.Memory = "2048Mi"; options.MaxConcurrentActivities = 1; @@ -28,4 +30,15 @@ static void AddEnvironmentVariable(OnDemandSandboxOptions options, string name) options.EnvironmentVariables[name] = value; } } + + static string GetRequiredEnvironmentVariable(string name) + { + string? value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException($"{name} must be set."); + } + + return value; + } } diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs index d36db486..09189a98 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs @@ -145,6 +145,9 @@ static Proto.OnDemandSandboxActivityImage BuildImage(OnDemandSandboxOptions opti Proto.OnDemandSandboxActivityImage image = new() { ImageRef = imageRef, + ManagedIdentityClientId = NormalizeRequired( + options.ImagePullManagedIdentityClientId ?? string.Empty, + "On-demand sandbox activity declaration requires the managed identity client ID ADC uses to pull the worker image."), }; return image; diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs index a27476f6..7bb8b31b 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs @@ -50,6 +50,11 @@ public sealed class OnDemandSandboxOptions /// public string? ImageDigest { get; set; } + /// + /// Gets or sets the user-assigned managed identity client ID ADC uses to pull the on-demand sandbox worker image. + /// + public string? ImagePullManagedIdentityClientId { get; set; } + /// /// Gets or sets the user-assigned managed identity client ID workers use to authenticate to the DTS scheduler. /// diff --git a/src/Grpc/on_demand_sandbox_activities_service.proto b/src/Grpc/on_demand_sandbox_activities_service.proto index 3c18b756..a5ff5b9a 100644 --- a/src/Grpc/on_demand_sandbox_activities_service.proto +++ b/src/Grpc/on_demand_sandbox_activities_service.proto @@ -70,6 +70,7 @@ message OnDemandSandboxActivityDeclaration { message OnDemandSandboxActivityImage { string image_ref = 1; + string managed_identity_client_id = 2; } message OnDemandSandboxActivityResources { diff --git a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs index e71fff31..54066e28 100644 --- a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs +++ b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs @@ -68,6 +68,7 @@ public async Task OnDemandSandboxActivityDeclarationHostedService_SendsDeclarati TaskHub = TaskHub, WorkerProfileId = "profile-a", ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", + ImagePullManagedIdentityClientId = "image-pull-client-id", SchedulerManagedIdentityClientId = "scheduler-client-id", Cpu = "500m", Memory = "1024Mi", @@ -95,6 +96,7 @@ public async Task OnDemandSandboxActivityDeclarationHostedService_SendsDeclarati declaration.WorkerProfileId.Should().Be("profile-a"); declaration.ActivityNames.Should().Equal("RemoteHello"); declaration.Image.ImageRef.Should().Be("mcr.microsoft.com/durabletask/demo-worker:1.0"); + declaration.Image.ManagedIdentityClientId.Should().Be("image-pull-client-id"); declaration.SchedulerManagedIdentityClientId.Should().Be("scheduler-client-id"); declaration.Resources.Cpu.Should().Be("500m"); declaration.Resources.Memory.Should().Be("1024Mi"); @@ -171,6 +173,23 @@ public void OnDemandSandboxActivityConfiguration_BuildDeclaration_RequiresSchedu .WithMessage("*managed identity client ID*"); } + [Fact] + public void OnDemandSandboxActivityConfiguration_BuildDeclaration_RequiresImagePullManagedIdentityClientId() + { + // Arrange + OnDemandSandboxOptions options = CreateDeclarationOptions(); + options.ImagePullManagedIdentityClientId = " "; + + // Act + Action action = () => OnDemandSandboxActivityConfiguration.BuildDeclaration( + options, + OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames)); + + // Assert + action.Should().Throw() + .WithMessage("*managed identity client ID ADC uses to pull the worker image*"); + } + [Fact] public async Task OnDemandSandboxActivitiesClientAdapter_SendsTaskHubMetadata() { @@ -247,6 +266,7 @@ public async Task OnDemandSandboxActivityDeclarationHostedService_OmitsEntrypoin { TaskHub = TaskHub, ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", + ImagePullManagedIdentityClientId = "image-pull-client-id", SchedulerManagedIdentityClientId = "scheduler-client-id", }; options.AddActivity("RemoteHello"); @@ -297,6 +317,7 @@ public async Task OnDemandSandboxActivityDeclarationHostedService_DoesNotRetryTr { TaskHub = TaskHub, ContainerImage = "example.com/repo/worker@sha256:abc", + ImagePullManagedIdentityClientId = "image-pull-client-id", SchedulerManagedIdentityClientId = "scheduler-client-id", }; options.AddActivity("RemoteHello"); @@ -697,6 +718,7 @@ public void OnDemandSandboxActivityDeclarationResolver_ResolveDeclarations_UsesW declaration.WorkerProfileId.Should().Be("annotated-profile"); declaration.ActivityNames.Should().Equal("ConfiguredRemoteHello"); declaration.Image.ImageRef.Should().Be("example.com/repo/annotated-worker:latest"); + declaration.Image.ManagedIdentityClientId.Should().Be("image-pull-client-id"); declaration.SchedulerManagedIdentityClientId.Should().Be("scheduler-client-id"); declaration.Resources.Cpu.Should().Be("500m"); declaration.Resources.Memory.Should().Be("1024Mi"); @@ -919,6 +941,7 @@ static OnDemandSandboxOptions CreateDeclarationOptions() TaskHub = TaskHub, WorkerProfileId = "profile-a", ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", + ImagePullManagedIdentityClientId = "image-pull-client-id", SchedulerManagedIdentityClientId = "scheduler-client-id", Cpu = "500m", Memory = "1024Mi", @@ -937,6 +960,7 @@ public void Configure(OnDemandSandboxOptions options) { ConfigureCallCount++; options.ContainerImage = "example.com/repo/annotated-worker:latest"; + options.ImagePullManagedIdentityClientId = "image-pull-client-id"; options.SchedulerManagedIdentityClientId = "scheduler-client-id"; options.Cpu = "500m"; options.Memory = "1024Mi"; From 604194f71554692f86be15a6436ad00831afeece Mon Sep 17 00:00:00 2001 From: wangbill Date: Tue, 9 Jun 2026 13:59:54 -0700 Subject: [PATCH 49/81] Move sandbox activity enablement to client Keep normal worker behavior as an explicit exclusion opt-in, declare sandbox activities through the management client, and simplify sandbox image configuration to a single full container image reference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/on-demand-sandbox/main-app/Program.cs | 6 +- .../Client/OnDemandSandboxActivitiesClient.cs | 48 +++++++++++- ...vitiesClientServiceCollectionExtensions.cs | 8 +- ...chedulerOnDemandSandboxWorkerExtensions.cs | 29 ++----- .../OnDemandSandboxActivityConfiguration.cs | 35 +-------- .../OnDemandSandbox/OnDemandSandboxOptions.cs | 24 +----- ...dSandboxActivitiesClientExtensionsTests.cs | 77 ++++++++++++++++--- .../OnDemandSandboxActivitiesTests.cs | 16 ++-- 8 files changed, 146 insertions(+), 97 deletions(-) diff --git a/samples/on-demand-sandbox/main-app/Program.cs b/samples/on-demand-sandbox/main-app/Program.cs index 650a3f18..50e68fec 100644 --- a/samples/on-demand-sandbox/main-app/Program.cs +++ b/samples/on-demand-sandbox/main-app/Program.cs @@ -38,7 +38,7 @@ options.Credential = credential; }); - workerBuilder.EnableSandboxActivities(); + workerBuilder.ExcludeOnDemandSandboxActivities(); }); builder.Services.AddDurableTaskClient(clientBuilder => @@ -50,11 +50,15 @@ options.Credential = credential; }); }); +builder.Services.AddDurableTaskSchedulerOnDemandSandboxActivitiesClient(); using IHost host = builder.Build(); await host.StartAsync(); +OnDemandSandboxActivitiesClient sandboxActivitiesClient = host.Services.GetRequiredService(); +await sandboxActivitiesClient.EnableSandboxActivitiesAsync(taskHub); + DurableTaskClient client = host.Services.GetRequiredService(); List instanceIds = new(orchestrationCount); for (int index = 1; index <= orchestrationCount; index++) diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs index 564224df..92e59785 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Grpc.Core; +using Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; namespace Microsoft.DurableTask.Client.AzureManaged; @@ -11,14 +13,54 @@ namespace Microsoft.DurableTask.Client.AzureManaged; public sealed class OnDemandSandboxActivitiesClient { readonly Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client; + readonly bool attachTaskHubMetadata; /// /// Initializes a new instance of the class. /// /// The generated gRPC client used to call DTS on-demand sandbox management operations. - internal OnDemandSandboxActivitiesClient(Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client) + /// True to add per-call task hub metadata when the underlying channel does not already do so. + internal OnDemandSandboxActivitiesClient( + Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client, + bool attachTaskHubMetadata = true) { this.client = client; + this.attachTaskHubMetadata = attachTaskHubMetadata; + } + + /// + /// Enables on-demand sandbox activities declared by worker profiles for a task hub. + /// + /// The task hub whose declarations should be sent to DTS. + /// The cancellation token used to cancel the request. + /// A task that completes when DTS accepts all declarations. + public async Task EnableSandboxActivitiesAsync( + string taskHub, + CancellationToken cancellation = default) + { + string normalizedTaskHub = string.IsNullOrWhiteSpace(taskHub) + ? throw new ArgumentException("Task hub name is required.", nameof(taskHub)) + : taskHub.Trim(); + + IReadOnlyList declarations = + OnDemandSandboxActivityDeclarationResolver.ResolveDeclarations(normalizedTaskHub); + foreach (OnDemandSandboxOptions options in declarations) + { + string[] activityNames = OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames); + if (activityNames.Length == 0) + { + continue; + } + + Proto.OnDemandSandboxActivityDeclaration declaration = + OnDemandSandboxActivityConfiguration.BuildDeclaration(options, activityNames); + using AsyncUnaryCall call = + this.client.DeclareOnDemandSandboxActivitiesAsync( + declaration, + headers: this.CreateTaskHubHeaders(normalizedTaskHub), + cancellationToken: cancellation); + await call.ResponseAsync.ConfigureAwait(false); + } } /// @@ -31,4 +73,8 @@ public Task RemoveOnDemandSandboxActivityDeclarationAsync( string workerProfileId, CancellationToken cancellation = default) => this.client.RemoveOnDemandSandboxActivityDeclarationAsync(workerProfileId, cancellation); + + Metadata? CreateTaskHubHeaders(string taskHub) => this.attachTaskHubMetadata + ? new Metadata { { "taskhub", taskHub }, } + : null; } diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs index fe08cebd..60c23e1c 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs @@ -43,12 +43,16 @@ public static IServiceCollection AddDurableTaskSchedulerOnDemandSandboxActivitie if (options.CallInvoker is { } callInvoker) { - return new OnDemandSandboxActivitiesClient(new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)); + return new OnDemandSandboxActivitiesClient( + new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker), + attachTaskHubMetadata: false); } if (options.Channel is GrpcChannel channel) { - return new OnDemandSandboxActivitiesClient(new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(channel.CreateCallInvoker())); + return new OnDemandSandboxActivitiesClient( + new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(channel.CreateCallInvoker()), + attachTaskHubMetadata: false); } throw new InvalidOperationException("DTS on-demand sandbox activity management requires a configured Durable Task Scheduler client."); diff --git a/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs b/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs index 384eed0f..7619c0fa 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs @@ -22,8 +22,8 @@ namespace Microsoft.DurableTask.Worker.AzureManaged; /// public static class DurableTaskSchedulerOnDemandSandboxWorkerExtensions { - const string EnableSandboxActivitiesWorkerRuntimeErrorMessage = - "Activity declaration is for declaring on-demand sandbox activities from the coordinator app. " + + const string ExcludeSandboxActivitiesWorkerRuntimeErrorMessage = + "On-demand sandbox activity exclusion is for normal app workers. " + "DTS on-demand sandbox workers should use UseSandboxWorker instead."; const string UseSandboxWorkerNoActivitiesErrorMessage = @@ -31,12 +31,13 @@ public static class DurableTaskSchedulerOnDemandSandboxWorkerExtensions "Register an activity on this worker before starting the sandbox worker."; /// - /// Enables annotation-based on-demand sandbox activity declarations with DTS and excludes annotated - /// on-demand sandbox activities from local execution. + /// Configures this worker to exclude activities declared for on-demand sandbox execution from local execution. + /// Use + /// from the client process to declare on-demand sandbox activities with DTS. /// /// The Durable Task worker builder to configure. /// The original builder, for call chaining. - public static IDurableTaskWorkerBuilder EnableSandboxActivities(this IDurableTaskWorkerBuilder builder) + public static IDurableTaskWorkerBuilder ExcludeOnDemandSandboxActivities(this IDurableTaskWorkerBuilder builder) { Check.NotNull(builder); ThrowIfOnDemandSandboxWorkerRuntime(); @@ -45,7 +46,6 @@ public static IDurableTaskWorkerBuilder EnableSandboxActivities(this IDurableTas .PostConfigure>((filters, schedulerOptions) => ExcludeDeclaredOnDemandSandboxActivitiesFromLocalExecution(filters, schedulerOptions.Get(builder.Name).TaskHubName)); - builder.Services.AddSingleton(sp => CreateOnDemandSandboxActivityDeclarationHostedService(sp, builder.Name)); return builder; } @@ -134,25 +134,10 @@ static void ThrowIfOnDemandSandboxWorkerRuntime() { if (IsOnDemandSandboxWorkerSubstrate(Environment.GetEnvironmentVariable("DTS_SUBSTRATE"))) { - throw new InvalidOperationException(EnableSandboxActivitiesWorkerRuntimeErrorMessage); + throw new InvalidOperationException(ExcludeSandboxActivitiesWorkerRuntimeErrorMessage); } } - static OnDemandSandboxActivityDeclarationHostedService CreateOnDemandSandboxActivityDeclarationHostedService( - IServiceProvider services, - string builderName) - { - ILoggerFactory loggerFactory = services.GetRequiredService(); - OnDemandSandboxWorkerRuntimeOptions runtimeOptions = services.GetRequiredService>().Get(builderName); - DurableTaskSchedulerWorkerOptions schedulerOptions = services.GetRequiredService>().Get(builderName); - - return new OnDemandSandboxActivityDeclarationHostedService( - CreateOnDemandSandboxActivitiesClient(services, builderName), - OnDemandSandboxActivityDeclarationResolver.ResolveDeclarations(schedulerOptions.TaskHubName), - runtimeOptions, - loggerFactory.CreateLogger()); - } - static OnDemandSandboxActivityWorkerRegistrationHostedService CreateOnDemandSandboxActivityWorkerRegistrationHostedService( IServiceProvider services, string builderName) diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs index 09189a98..693291c5 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs @@ -133,14 +133,9 @@ public static Proto.OnDemandSandboxActivityWorkerMessage BuildWorkerHeartbeat(in static Proto.OnDemandSandboxActivityImage BuildImage(OnDemandSandboxOptions options) { - string? imageRef = Coalesce( - options.ContainerImage, - BuildImageRef(options.RegistryServer, options.Repository, options.Tag, options.ImageDigest)); - - if (string.IsNullOrWhiteSpace(imageRef)) - { - throw new InvalidOperationException("On-demand sandbox activity image metadata requires a container image reference."); - } + string imageRef = NormalizeRequired( + options.ContainerImage ?? string.Empty, + "On-demand sandbox activity image metadata requires a container image reference like 'myregistry.azurecr.io/workers/hello:1.0' or 'myregistry.azurecr.io/workers/hello@sha256:...'."); Proto.OnDemandSandboxActivityImage image = new() { @@ -294,28 +289,4 @@ static string[] NormalizeOptionalStrings(IEnumerable values) .Select(static value => value.Trim()) .ToArray(); } - - static string? BuildImageRef(string? registryServer, string? repository, string? tag, string? digest) - { - if (string.IsNullOrWhiteSpace(repository)) - { - return null; - } - - string image = string.IsNullOrWhiteSpace(registryServer) ? repository : $"{registryServer}/{repository}"; - if (!string.IsNullOrWhiteSpace(digest)) - { - return $"{image}@{digest}"; - } - - return string.IsNullOrWhiteSpace(tag) ? image : $"{image}:{tag}"; - } - - static string? Coalesce(params string?[] values) - { - return values - .Where(static value => !string.IsNullOrWhiteSpace(value)) - .Select(static value => value!.Trim()) - .FirstOrDefault(); - } } diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs index 7bb8b31b..7a919c40 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs @@ -26,30 +26,12 @@ public sealed class OnDemandSandboxOptions public string WorkerProfileId { get; set; } = DefaultWorkerProfileId; /// - /// Gets or sets the full container image reference for on-demand sandbox workers. + /// Gets or sets the full OCI container image reference for on-demand sandbox workers. + /// Examples: myregistry.azurecr.io/workers/hello:1.0 or + /// myregistry.azurecr.io/workers/hello@sha256:0123456789abcdef.... /// public string? ContainerImage { get; set; } - /// - /// Gets or sets the registry server for the on-demand sandbox worker image. - /// - public string? RegistryServer { get; set; } - - /// - /// Gets or sets the repository for the on-demand sandbox worker image. - /// - public string? Repository { get; set; } - - /// - /// Gets or sets the tag for the on-demand sandbox worker image. - /// - public string? Tag { get; set; } - - /// - /// Gets or sets the digest for the on-demand sandbox worker image. - /// - public string? ImageDigest { get; set; } - /// /// Gets or sets the user-assigned managed identity client ID ADC uses to pull the on-demand sandbox worker image. /// diff --git a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientExtensionsTests.cs b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientExtensionsTests.cs index 294d9c1f..afddfda2 100644 --- a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientExtensionsTests.cs +++ b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientExtensionsTests.cs @@ -5,6 +5,7 @@ using Grpc.Core; using Microsoft.DurableTask.Client.Grpc; using Microsoft.DurableTask.Protobuf.OnDemandSandbox; +using Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Xunit; @@ -34,6 +35,28 @@ public async Task AddDurableTaskSchedulerOnDemandSandboxActivitiesClient_UsesCon callInvoker.RemoveRequest!.WorkerProfileId.Should().Be("default"); } + [Fact] + public async Task EnableSandboxActivitiesAsync_SendsWorkerProfileDeclarations() + { + // Arrange + RecordingOnDemandSandboxLogCallInvoker callInvoker = new(); + OnDemandSandboxActivitiesClient client = new( + new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)); + + // Act + await client.EnableSandboxActivitiesAsync("client-test-taskhub"); + + // Assert + OnDemandSandboxActivityDeclaration declaration = callInvoker.DeclareRequests + .Should() + .ContainSingle(request => request.WorkerProfileId == "client-test-profile") + .Subject; + declaration.ActivityNames.Should().Equal("ClientTestRemoteActivity"); + declaration.Image.ImageRef.Should().Be("example.com/client-test-worker:latest"); + callInvoker.DeclareHeaders.Should().Contain(header => header.Key == "taskhub" && header.Value == "client-test-taskhub"); + callInvoker.UnaryDisposeCount.Should().BeGreaterThan(0); + } + [Fact] public async Task RemoveOnDemandSandboxActivityDeclarationAsync_SendsRequest() { @@ -53,6 +76,10 @@ public async Task RemoveOnDemandSandboxActivityDeclarationAsync_SendsRequest() sealed class RecordingOnDemandSandboxLogCallInvoker : CallInvoker { + public List DeclareRequests { get; } = []; + + public Metadata DeclareHeaders { get; private set; } = []; + public RemoveOnDemandSandboxActivityDeclarationRequest? RemoveRequest { get; private set; } public Metadata RemoveHeaders { get; private set; } = []; @@ -74,16 +101,21 @@ public override AsyncUnaryCall AsyncUnaryCall( CallOptions options, TRequest request) { - method.FullName.Should().EndWith("/RemoveOnDemandSandboxActivityDeclaration"); - this.RemoveRequest = (RemoveOnDemandSandboxActivityDeclarationRequest)(object)request; - this.RemoveHeaders = options.Headers ?? []; - - return new AsyncUnaryCall( - Task.FromResult((TResponse)(object)new RemoveOnDemandSandboxActivityDeclarationResult()), - Task.FromResult(new Metadata()), - () => new Status(StatusCode.OK, string.Empty), - () => new Metadata(), - () => this.UnaryDisposeCount++); + if (method.FullName.EndsWith("/DeclareOnDemandSandboxActivities", StringComparison.Ordinal)) + { + this.DeclareRequests.Add(((OnDemandSandboxActivityDeclaration)(object)request).Clone()); + this.DeclareHeaders = options.Headers ?? []; + return CreateUnaryCall((TResponse)(object)new OnDemandSandboxActivityDeclarationResult()); + } + + if (method.FullName.EndsWith("/RemoveOnDemandSandboxActivityDeclaration", StringComparison.Ordinal)) + { + this.RemoveRequest = (RemoveOnDemandSandboxActivityDeclarationRequest)(object)request; + this.RemoveHeaders = options.Headers ?? []; + return CreateUnaryCall((TResponse)(object)new RemoveOnDemandSandboxActivityDeclarationResult()); + } + + throw new NotSupportedException(method.FullName); } public override AsyncServerStreamingCall AsyncServerStreamingCall( @@ -110,5 +142,30 @@ public override AsyncDuplexStreamingCall AsyncDuplexStreami { throw new NotSupportedException(); } + + AsyncUnaryCall CreateUnaryCall(TResponse response) + { + return new AsyncUnaryCall( + Task.FromResult(response), + Task.FromResult(new Metadata()), + () => new Status(StatusCode.OK, string.Empty), + () => new Metadata(), + () => this.UnaryDisposeCount++); + } + } + + [OnDemandSandboxWorkerProfile("client-test-profile")] + sealed class ClientTestWorkerProfile : ISandboxWorkerProfile + { + public void Configure(OnDemandSandboxOptions options) + { + options.ContainerImage = "example.com/client-test-worker:latest"; + options.ImagePullManagedIdentityClientId = "image-pull-client-id"; + options.SchedulerManagedIdentityClientId = "scheduler-client-id"; + options.Cpu = "500m"; + options.Memory = "1024Mi"; + options.MaxConcurrentActivities = 4; + options.AddActivity("ClientTestRemoteActivity"); + } } } diff --git a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs index 54066e28..31e954ae 100644 --- a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs +++ b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs @@ -637,7 +637,7 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_StopAsy } [Fact] - public async Task EnableSandboxActivities_ConfiguresLocalWorkerExclusionFilterFromWorkerProfiles() + public async Task ExcludeOnDemandSandboxActivities_ConfiguresLocalWorkerExclusionFilterFromWorkerProfiles() { // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); @@ -651,7 +651,7 @@ public async Task EnableSandboxActivities_ConfiguresLocalWorkerExclusionFilterFr mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - mockBuilder.Object.EnableSandboxActivities(); + mockBuilder.Object.ExcludeOnDemandSandboxActivities(); await using ServiceProvider provider = services.BuildServiceProvider(); DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); @@ -662,7 +662,7 @@ public async Task EnableSandboxActivities_ConfiguresLocalWorkerExclusionFilterFr } [Fact] - public void EnableSandboxActivities_RegistersDeclarationHostedService() + public void ExcludeOnDemandSandboxActivities_DoesNotRegisterDeclarationHostedService() { // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); @@ -673,14 +673,14 @@ public void EnableSandboxActivities_RegistersDeclarationHostedService() mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - mockBuilder.Object.EnableSandboxActivities(); + mockBuilder.Object.ExcludeOnDemandSandboxActivities(); // Assert - services.Should().Contain(descriptor => descriptor.ServiceType == typeof(IHostedService)); + services.Should().NotContain(descriptor => descriptor.ServiceType == typeof(IHostedService)); } [Fact] - public void EnableSandboxActivities_WhenRunningInOnDemandSandboxWorker_Throws() + public void ExcludeOnDemandSandboxActivities_WhenRunningInOnDemandSandboxWorker_Throws() { // Arrange using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "Sandbox"); @@ -690,11 +690,11 @@ public void EnableSandboxActivities_WhenRunningInOnDemandSandboxWorker_Throws() mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); // Act - Action action = () => mockBuilder.Object.EnableSandboxActivities(); + Action action = () => mockBuilder.Object.ExcludeOnDemandSandboxActivities(); // Assert action.Should().Throw() - .WithMessage("Activity declaration is for declaring on-demand sandbox activities from the coordinator app. DTS on-demand sandbox workers should use UseSandboxWorker instead."); + .WithMessage("On-demand sandbox activity exclusion is for normal app workers. DTS on-demand sandbox workers should use UseSandboxWorker instead."); services.Should().NotContain(descriptor => descriptor.ServiceType == typeof(IHostedService)); } From 5722ae6ccd58c4e664555b91502e10807f1c3e0e Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 10 Jun 2026 13:09:10 -0700 Subject: [PATCH 50/81] Align on-demand sandbox .NET APIs with Python Remove the stale activity exclusion API and proto field, capture the task hub in the on-demand sandbox client, and drop obsolete reserved worker_instance_id metadata from the local proto copy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/on-demand-sandbox/main-app/Program.cs | 4 +- .../Client/OnDemandSandboxActivitiesClient.cs | 25 ++- ...vitiesClientServiceCollectionExtensions.cs | 5 + ...chedulerOnDemandSandboxWorkerExtensions.cs | 63 ------ ...SandboxActivityDeclarationHostedService.cs | 104 --------- ...emandSandboxActivityDeclarationResolver.cs | 13 -- ...on_demand_sandbox_activities_service.proto | 3 - src/Grpc/orchestrator_service.proto | 5 - .../DurableTaskWorkerBuilderExtensions.cs | 3 - ...rableTaskWorkerWorkItemFiltersValidator.cs | 5 - .../Core/DurableTaskWorkerWorkItemFilters.cs | 6 - ...rableTaskWorkerWorkItemFiltersExtension.cs | 10 - ...dSandboxActivitiesClientExtensionsTests.cs | 9 +- .../OnDemandSandboxActivitiesTests.cs | 204 ------------------ 14 files changed, 24 insertions(+), 435 deletions(-) delete mode 100644 src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationHostedService.cs diff --git a/samples/on-demand-sandbox/main-app/Program.cs b/samples/on-demand-sandbox/main-app/Program.cs index 50e68fec..74f6b0ce 100644 --- a/samples/on-demand-sandbox/main-app/Program.cs +++ b/samples/on-demand-sandbox/main-app/Program.cs @@ -37,8 +37,6 @@ options.TaskHubName = taskHub; options.Credential = credential; }); - - workerBuilder.ExcludeOnDemandSandboxActivities(); }); builder.Services.AddDurableTaskClient(clientBuilder => @@ -57,7 +55,7 @@ await host.StartAsync(); OnDemandSandboxActivitiesClient sandboxActivitiesClient = host.Services.GetRequiredService(); -await sandboxActivitiesClient.EnableSandboxActivitiesAsync(taskHub); +await sandboxActivitiesClient.EnableOnDemandSandboxActivitiesAsync(); DurableTaskClient client = host.Services.GetRequiredService(); List instanceIds = new(orchestrationCount); diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs index 92e59785..8932ccd5 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs @@ -13,37 +13,36 @@ namespace Microsoft.DurableTask.Client.AzureManaged; public sealed class OnDemandSandboxActivitiesClient { readonly Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client; + readonly string taskHub; readonly bool attachTaskHubMetadata; /// /// Initializes a new instance of the class. /// /// The generated gRPC client used to call DTS on-demand sandbox management operations. + /// The task hub whose declarations should be sent to DTS. /// True to add per-call task hub metadata when the underlying channel does not already do so. internal OnDemandSandboxActivitiesClient( Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client, + string taskHub, bool attachTaskHubMetadata = true) { this.client = client; + this.taskHub = string.IsNullOrWhiteSpace(taskHub) + ? throw new ArgumentException("Task hub name is required.", nameof(taskHub)) + : taskHub.Trim(); this.attachTaskHubMetadata = attachTaskHubMetadata; } /// - /// Enables on-demand sandbox activities declared by worker profiles for a task hub. + /// Enables on-demand sandbox activities declared by worker profiles for the configured task hub. /// - /// The task hub whose declarations should be sent to DTS. /// The cancellation token used to cancel the request. /// A task that completes when DTS accepts all declarations. - public async Task EnableSandboxActivitiesAsync( - string taskHub, - CancellationToken cancellation = default) + public async Task EnableOnDemandSandboxActivitiesAsync(CancellationToken cancellation = default) { - string normalizedTaskHub = string.IsNullOrWhiteSpace(taskHub) - ? throw new ArgumentException("Task hub name is required.", nameof(taskHub)) - : taskHub.Trim(); - IReadOnlyList declarations = - OnDemandSandboxActivityDeclarationResolver.ResolveDeclarations(normalizedTaskHub); + OnDemandSandboxActivityDeclarationResolver.ResolveDeclarations(this.taskHub); foreach (OnDemandSandboxOptions options in declarations) { string[] activityNames = OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames); @@ -57,7 +56,7 @@ public async Task EnableSandboxActivitiesAsync( using AsyncUnaryCall call = this.client.DeclareOnDemandSandboxActivitiesAsync( declaration, - headers: this.CreateTaskHubHeaders(normalizedTaskHub), + headers: this.CreateTaskHubHeaders(), cancellationToken: cancellation); await call.ResponseAsync.ConfigureAwait(false); } @@ -74,7 +73,7 @@ public Task RemoveOnDemandSandboxActivityDeclarationAsync( CancellationToken cancellation = default) => this.client.RemoveOnDemandSandboxActivityDeclarationAsync(workerProfileId, cancellation); - Metadata? CreateTaskHubHeaders(string taskHub) => this.attachTaskHubMetadata - ? new Metadata { { "taskhub", taskHub }, } + Metadata? CreateTaskHubHeaders() => this.attachTaskHubMetadata + ? new Metadata { { "taskhub", this.taskHub }, } : null; } diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs index 60c23e1c..019b2d95 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs @@ -37,6 +37,9 @@ public static IServiceCollection AddDurableTaskSchedulerOnDemandSandboxActivitie services.AddSingleton(provider => { + DurableTaskSchedulerClientOptions schedulerOptions = provider + .GetRequiredService>() + .Get(clientName); GrpcDurableTaskClientOptions options = provider .GetRequiredService>() .Get(clientName); @@ -45,6 +48,7 @@ public static IServiceCollection AddDurableTaskSchedulerOnDemandSandboxActivitie { return new OnDemandSandboxActivitiesClient( new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker), + schedulerOptions.TaskHubName, attachTaskHubMetadata: false); } @@ -52,6 +56,7 @@ public static IServiceCollection AddDurableTaskSchedulerOnDemandSandboxActivitie { return new OnDemandSandboxActivitiesClient( new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(channel.CreateCallInvoker()), + schedulerOptions.TaskHubName, attachTaskHubMetadata: false); } diff --git a/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs b/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs index 7619c0fa..c5c039fb 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs @@ -22,33 +22,10 @@ namespace Microsoft.DurableTask.Worker.AzureManaged; /// public static class DurableTaskSchedulerOnDemandSandboxWorkerExtensions { - const string ExcludeSandboxActivitiesWorkerRuntimeErrorMessage = - "On-demand sandbox activity exclusion is for normal app workers. " + - "DTS on-demand sandbox workers should use UseSandboxWorker instead."; - const string UseSandboxWorkerNoActivitiesErrorMessage = "On-demand sandbox workers require at least one registered activity. " + "Register an activity on this worker before starting the sandbox worker."; - /// - /// Configures this worker to exclude activities declared for on-demand sandbox execution from local execution. - /// Use - /// from the client process to declare on-demand sandbox activities with DTS. - /// - /// The Durable Task worker builder to configure. - /// The original builder, for call chaining. - public static IDurableTaskWorkerBuilder ExcludeOnDemandSandboxActivities(this IDurableTaskWorkerBuilder builder) - { - Check.NotNull(builder); - ThrowIfOnDemandSandboxWorkerRuntime(); - - builder.Services.AddOptions(builder.Name) - .PostConfigure>((filters, schedulerOptions) => - ExcludeDeclaredOnDemandSandboxActivitiesFromLocalExecution(filters, schedulerOptions.Get(builder.Name).TaskHubName)); - - return builder; - } - /// /// Configures this worker as an on-demand sandbox activity worker that connects to DTS to receive and execute /// on-demand sandbox activities. Use this on a dedicated worker binary that runs inside sandbox infrastructure. @@ -96,19 +73,6 @@ public static IDurableTaskWorkerBuilder UseSandboxWorker(this IDurableTaskWorker return builder; } - static void ExcludeDeclaredOnDemandSandboxActivitiesFromLocalExecution( - DurableTaskWorkerWorkItemFilters filters, - string taskHub) - { - string[] activityNames = OnDemandSandboxActivityDeclarationResolver.ResolveDeclaredActivityNames(taskHub); - if (activityNames.Length == 0) - { - return; - } - - filters.ExcludedActivities = MergeActivityFilters(filters.ExcludedActivities, activityNames); - } - static void IncludeOnlyRegisteredActivities(DurableTaskWorkerWorkItemFilters filters) { if (filters.Activities.Count == 0) @@ -117,7 +81,6 @@ static void IncludeOnlyRegisteredActivities(DurableTaskWorkerWorkItemFilters fil } filters.Orchestrations = []; - filters.ExcludedActivities = []; filters.Entities = []; } @@ -130,14 +93,6 @@ static void ConfigureOnDemandSandboxWorkerConcurrency( options.Concurrency.MaximumConcurrentEntityWorkItems = 0; } - static void ThrowIfOnDemandSandboxWorkerRuntime() - { - if (IsOnDemandSandboxWorkerSubstrate(Environment.GetEnvironmentVariable("DTS_SUBSTRATE"))) - { - throw new InvalidOperationException(ExcludeSandboxActivitiesWorkerRuntimeErrorMessage); - } - } - static OnDemandSandboxActivityWorkerRegistrationHostedService CreateOnDemandSandboxActivityWorkerRegistrationHostedService( IServiceProvider services, string builderName) @@ -245,24 +200,6 @@ static bool IsOnDemandSandboxWorkerSubstrate(string? substrate) => string.Equals(substrate, "Sandbox", StringComparison.OrdinalIgnoreCase) || string.Equals(substrate, "AcaSessionPool", StringComparison.OrdinalIgnoreCase); - static DurableTaskWorkerWorkItemFilters.ActivityFilter[] MergeActivityFilters( - IReadOnlyList existingFilters, - IEnumerable activityNames) - { - Dictionary merged = new(StringComparer.OrdinalIgnoreCase); - foreach (DurableTaskWorkerWorkItemFilters.ActivityFilter filter in existingFilters.Where(static filter => !string.IsNullOrWhiteSpace(filter.Name))) - { - merged[filter.Name] = filter; - } - - foreach (string activityName in activityNames) - { - merged[activityName] = new DurableTaskWorkerWorkItemFilters.ActivityFilter { Name = activityName }; - } - - return merged.Values.ToArray(); - } - static string[] ResolveActivityFilterNames(IReadOnlyList activityFilters) { return activityFilters diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationHostedService.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationHostedService.cs deleted file mode 100644 index b725b77c..00000000 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationHostedService.cs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; - -namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; - -/// -/// Hosted service that declares on-demand sandbox activities with DTS when the local worker starts. -/// -sealed class OnDemandSandboxActivityDeclarationHostedService : IHostedService -{ - readonly IOnDemandSandboxActivitiesClient client; - readonly IReadOnlyList declarations; - readonly OnDemandSandboxWorkerRuntimeOptions? runtimeOptions; - readonly ILogger logger; - - /// - /// Initializes a new instance of the class. - /// - /// The on-demand sandbox activities client. - /// The on-demand sandbox declaration options. - /// The optional on-demand sandbox worker runtime options. - /// The logger. - public OnDemandSandboxActivityDeclarationHostedService( - IOnDemandSandboxActivitiesClient client, - IReadOnlyList declarations, - OnDemandSandboxWorkerRuntimeOptions? runtimeOptions, - ILogger logger) - { - this.client = Check.NotNull(client); - this.declarations = Check.NotNull(declarations); - this.runtimeOptions = runtimeOptions; - this.logger = Check.NotNull(logger); - } - - /// - /// Initializes a new instance of the class. - /// - /// The on-demand sandbox activities client. - /// The on-demand sandbox declaration options. - /// The optional on-demand sandbox worker runtime options. - /// The logger. - public OnDemandSandboxActivityDeclarationHostedService( - IOnDemandSandboxActivitiesClient client, - OnDemandSandboxOptions declaration, - OnDemandSandboxWorkerRuntimeOptions? runtimeOptions, - ILogger logger) - : this(client, [declaration], runtimeOptions, logger) - { - } - - /// - public async Task StartAsync(CancellationToken cancellationToken) - { - if (this.runtimeOptions?.Mode == OnDemandSandboxMode.OnDemandSandboxInclude) - { - return; - } - - if (this.declarations.Count == 0) - { - Logs.NoOnDemandSandboxActivitiesForDeclaration(this.logger, string.Empty); - return; - } - - foreach (OnDemandSandboxOptions options in this.declarations) - { - string[] activityNames = OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames); - if (activityNames.Length == 0) - { - Logs.NoOnDemandSandboxActivitiesForDeclaration(this.logger, options.TaskHub); - continue; - } - - Proto.OnDemandSandboxActivityDeclaration declaration = OnDemandSandboxActivityConfiguration.BuildDeclaration( - options, - activityNames); - try - { - await this.client.DeclareOnDemandSandboxActivitiesAsync( - declaration, - options.TaskHub, - cancellationToken).ConfigureAwait(false); - Logs.OnDemandSandboxActivitiesDeclared( - this.logger, - options.TaskHub, - declaration.WorkerProfileId, - declaration.ActivityNames.Count, - declaration.Image?.ImageRef ?? string.Empty); - } - catch (Exception ex) - { - Logs.OnDemandSandboxActivityDeclarationFailed(this.logger, ex, options.TaskHub); - throw; - } - } - } - - /// - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; -} diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs index 05ef881f..237890cd 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs @@ -33,19 +33,6 @@ public static IReadOnlyList ResolveDeclarations(string t return declarations; } - /// - /// Resolves activity names declared by on-demand sandbox worker profiles. - /// - /// The task hub name. - /// The resolved activity names. - public static string[] ResolveDeclaredActivityNames(string taskHub) - { - return ResolveDeclarations(taskHub) - .SelectMany(static options => OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames)) - .Distinct(StringComparer.Ordinal) - .ToArray(); - } - static ProfileMetadata[] ScanProfiles() { Dictionary profiles = new(StringComparer.Ordinal); diff --git a/src/Grpc/on_demand_sandbox_activities_service.proto b/src/Grpc/on_demand_sandbox_activities_service.proto index a5ff5b9a..cce5e0c1 100644 --- a/src/Grpc/on_demand_sandbox_activities_service.proto +++ b/src/Grpc/on_demand_sandbox_activities_service.proto @@ -32,9 +32,6 @@ message OnDemandSandboxActivityWorkerMessage { } message OnDemandSandboxActivityWorkerStart { - reserved 2; - reserved "worker_instance_id"; - string task_hub = 1; int32 max_activities_count = 3; // Substrate the worker is running in. UNSPECIFIED = legacy (pre-substrate-aware) workers. diff --git a/src/Grpc/orchestrator_service.proto b/src/Grpc/orchestrator_service.proto index f782a5fe..3d7c8eb4 100644 --- a/src/Grpc/orchestrator_service.proto +++ b/src/Grpc/orchestrator_service.proto @@ -856,11 +856,6 @@ message WorkItemFilters { repeated OrchestrationFilter orchestrations = 1; repeated ActivityFilter activities = 2; repeated EntityFilter entities = 3; - // Activities the worker explicitly does NOT want to process. When set, - // matching activity work items are skipped for this connection even if - // they would otherwise match `activities`. Mutually exclusive with - // `activities` for the same name. - repeated ActivityFilter exclude_activities = 4; } message OrchestrationFilter { diff --git a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs index 50e967ae..a9274078 100644 --- a/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerBuilderExtensions.cs @@ -171,14 +171,12 @@ public static IDurableTaskWorkerBuilder UseWorkItemFilters(this IDurableTaskWork { opts.Orchestrations = []; opts.Activities = []; - opts.ExcludedActivities = []; opts.Entities = []; } else { opts.Orchestrations = workItemFilters.Orchestrations; opts.Activities = workItemFilters.Activities; - opts.ExcludedActivities = workItemFilters.ExcludedActivities; opts.Entities = workItemFilters.Entities; } }); @@ -196,7 +194,6 @@ public static IDurableTaskWorkerBuilder UseWorkItemFilters(this IDurableTaskWork if (workItemFilters is not null && (workItemFilters.Orchestrations.Count > 0 || workItemFilters.Activities.Count > 0 - || workItemFilters.ExcludedActivities.Count > 0 || workItemFilters.Entities.Count > 0)) { builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton< diff --git a/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs b/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs index 9d79a11f..888cf7c0 100644 --- a/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs +++ b/src/Worker/Core/DependencyInjection/DurableTaskWorkerWorkItemFiltersValidator.cs @@ -42,7 +42,6 @@ public ValidateOptionsResult Validate(string? name, DurableTaskWorkerWorkItemFil // reports a verdict for workers that actually configured filters. if (options.Orchestrations.Count == 0 && options.Activities.Count == 0 - && options.ExcludedActivities.Count == 0 && options.Entities.Count == 0) { return ValidateOptionsResult.Skip; @@ -61,14 +60,11 @@ public ValidateOptionsResult Validate(string? name, DurableTaskWorkerWorkItemFil options.Orchestrations.Select(o => o.Name), registeredOrchestratorNames.Contains); List unknownActivities = FindUnknown( options.Activities.Select(a => a.Name), registeredActivityNames.Contains); - List unknownExcludedActivities = FindUnknown( - options.ExcludedActivities.Select(a => a.Name), registeredActivityNames.Contains); List unknownEntities = FindUnknown( options.Entities.Select(e => e.Name), n => registry.Entities.ContainsKey(new TaskName(n))); if (unknownOrchestrations.Count == 0 && unknownActivities.Count == 0 - && unknownExcludedActivities.Count == 0 && unknownEntities.Count == 0) { return ValidateOptionsResult.Success; @@ -82,7 +78,6 @@ public ValidateOptionsResult Validate(string? name, DurableTaskWorkerWorkItemFil .Append("or remove them from the filters."); AppendCategory(sb, "Orchestrations", unknownOrchestrations); AppendCategory(sb, "Activities", unknownActivities); - AppendCategory(sb, "ExcludedActivities", unknownExcludedActivities); AppendCategory(sb, "Entities", unknownEntities); return ValidateOptionsResult.Fail(sb.ToString()); diff --git a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs index eb3ddf25..ccedc3b1 100644 --- a/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs +++ b/src/Worker/Core/DurableTaskWorkerWorkItemFilters.cs @@ -22,11 +22,6 @@ public class DurableTaskWorkerWorkItemFilters /// public IReadOnlyList Activities { get; set; } = []; - /// - /// Gets or sets the activity filters that should be excluded from this worker connection. - /// - public IReadOnlyList ExcludedActivities { get; set; } = []; - /// /// Gets or sets the entity filters. /// @@ -90,7 +85,6 @@ internal static DurableTaskWorkerWorkItemFilters FromDurableTaskRegistry(Durable { Orchestrations = orchestrationFilters, Activities = activityFilters, - ExcludedActivities = [], Entities = registry.Entities.Select(entity => new EntityFilter { // Entity names are normalized to lowercase in the backend. diff --git a/src/Worker/Grpc/Internal/DurableTaskWorkerWorkItemFiltersExtension.cs b/src/Worker/Grpc/Internal/DurableTaskWorkerWorkItemFiltersExtension.cs index 63c2b052..176d376c 100644 --- a/src/Worker/Grpc/Internal/DurableTaskWorkerWorkItemFiltersExtension.cs +++ b/src/Worker/Grpc/Internal/DurableTaskWorkerWorkItemFiltersExtension.cs @@ -39,16 +39,6 @@ public static P.WorkItemFilters ToGrpcWorkItemFilters(this DurableTaskWorkerWork grpcWorkItemFilters.Activities.Add(grpcActivityFilter); } - foreach (var activityFilter in workItemFilter.ExcludedActivities) - { - var grpcActivityFilter = new P.ActivityFilter - { - Name = activityFilter.Name, - }; - grpcActivityFilter.Versions.AddRange(activityFilter.Versions); - grpcWorkItemFilters.ExcludeActivities.Add(grpcActivityFilter); - } - foreach (var entityFilter in workItemFilter.Entities) { var grpcEntityFilter = new P.EntityFilter diff --git a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientExtensionsTests.cs b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientExtensionsTests.cs index afddfda2..7b8902a5 100644 --- a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientExtensionsTests.cs +++ b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientExtensionsTests.cs @@ -20,6 +20,8 @@ public async Task AddDurableTaskSchedulerOnDemandSandboxActivitiesClient_UsesCon // Arrange RecordingOnDemandSandboxLogCallInvoker callInvoker = new(); ServiceCollection services = new(); + services.AddOptions(Options.DefaultName) + .Configure(options => options.TaskHubName = "client-test-taskhub"); services.AddOptions(Options.DefaultName) .Configure(options => options.CallInvoker = callInvoker); services.AddDurableTaskSchedulerOnDemandSandboxActivitiesClient(); @@ -36,15 +38,16 @@ public async Task AddDurableTaskSchedulerOnDemandSandboxActivitiesClient_UsesCon } [Fact] - public async Task EnableSandboxActivitiesAsync_SendsWorkerProfileDeclarations() + public async Task EnableOnDemandSandboxActivitiesAsync_SendsWorkerProfileDeclarations() { // Arrange RecordingOnDemandSandboxLogCallInvoker callInvoker = new(); OnDemandSandboxActivitiesClient client = new( - new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)); + new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker), + "client-test-taskhub"); // Act - await client.EnableSandboxActivitiesAsync("client-test-taskhub"); + await client.EnableOnDemandSandboxActivitiesAsync(); // Assert OnDemandSandboxActivityDeclaration declaration = callInvoker.DeclareRequests diff --git a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs index 31e954ae..0ff3ec8f 100644 --- a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs +++ b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs @@ -59,53 +59,6 @@ public void OnDemandSandboxDeclarationContract_ExposesProfileAddActivityOnly() activityAttributeType.Should().BeNull(); } - [Fact] - public async Task OnDemandSandboxActivityDeclarationHostedService_SendsDeclarationPayload() - { - // Arrange - OnDemandSandboxOptions options = new() - { - TaskHub = TaskHub, - WorkerProfileId = "profile-a", - ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", - ImagePullManagedIdentityClientId = "image-pull-client-id", - SchedulerManagedIdentityClientId = "scheduler-client-id", - Cpu = "500m", - Memory = "1024Mi", - MaxConcurrentActivities = 7, - }; - options.AddActivity("RemoteHello"); - options.EnvironmentVariables.Add("CUSTOM_SETTING", "enabled"); - options.Entrypoint.Add("/usr/bin/tini"); - options.Entrypoint.Add("--"); - options.Cmd.Add("dotnet"); - options.Cmd.Add("/app/DemoWorker.dll"); - FakeOnDemandSandboxActivitiesClient client = new(); - OnDemandSandboxActivityDeclarationHostedService service = new( - client, - options, - runtimeOptions: null, - NullLogger.Instance); - - // Act - await service.StartAsync(CancellationToken.None); - - // Assert - OnDemandSandboxActivityDeclaration declaration = client.Declarations.Should().ContainSingle().Subject; - client.DeclarationTaskHubs.Should().Equal(TaskHub); - declaration.WorkerProfileId.Should().Be("profile-a"); - declaration.ActivityNames.Should().Equal("RemoteHello"); - declaration.Image.ImageRef.Should().Be("mcr.microsoft.com/durabletask/demo-worker:1.0"); - declaration.Image.ManagedIdentityClientId.Should().Be("image-pull-client-id"); - declaration.SchedulerManagedIdentityClientId.Should().Be("scheduler-client-id"); - declaration.Resources.Cpu.Should().Be("500m"); - declaration.Resources.Memory.Should().Be("1024Mi"); - declaration.EnvironmentVariables.Should().ContainKey("CUSTOM_SETTING").WhoseValue.Should().Be("enabled"); - declaration.Entrypoint.Should().Equal("/usr/bin/tini", "--"); - declaration.Cmd.Should().Equal("dotnet", "/app/DemoWorker.dll"); - declaration.MaxConcurrentActivities.Should().Be(7); - } - [Theory] [InlineData("500m", "1024Mi")] [InlineData("0.5", "1Gi")] @@ -258,86 +211,6 @@ public async Task OnDemandSandboxActivitiesClientAdapter_CanRelyOnChannelTaskHub callInvoker.WorkerSessionHeaders.Should().NotContain(header => header.Key == "taskhub"); } - [Fact] - public async Task OnDemandSandboxActivityDeclarationHostedService_OmitsEntrypointAndCmdByDefault() - { - // Arrange - OnDemandSandboxOptions options = new() - { - TaskHub = TaskHub, - ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", - ImagePullManagedIdentityClientId = "image-pull-client-id", - SchedulerManagedIdentityClientId = "scheduler-client-id", - }; - options.AddActivity("RemoteHello"); - FakeOnDemandSandboxActivitiesClient client = new(); - OnDemandSandboxActivityDeclarationHostedService service = new( - client, - options, - runtimeOptions: null, - NullLogger.Instance); - - // Act - await service.StartAsync(CancellationToken.None); - - // Assert - OnDemandSandboxActivityDeclaration declaration = client.Declarations.Should().ContainSingle().Subject; - declaration.Entrypoint.Should().BeEmpty(); - declaration.Cmd.Should().BeEmpty(); - } - - [Fact] - public async Task OnDemandSandboxActivityDeclarationHostedService_SkipsDeclarationWhenNamesAreEmpty() - { - // Arrange - OnDemandSandboxOptions options = new() - { - TaskHub = TaskHub, - ContainerImage = "example.com/repo/worker:latest", - }; - FakeOnDemandSandboxActivitiesClient client = new(); - OnDemandSandboxActivityDeclarationHostedService service = new( - client, - options, - runtimeOptions: null, - NullLogger.Instance); - - // Act - await service.StartAsync(CancellationToken.None); - - // Assert - client.Declarations.Should().BeEmpty(); - } - - [Fact] - public async Task OnDemandSandboxActivityDeclarationHostedService_DoesNotRetryTransientFailures() - { - // Arrange - OnDemandSandboxOptions options = new() - { - TaskHub = TaskHub, - ContainerImage = "example.com/repo/worker@sha256:abc", - ImagePullManagedIdentityClientId = "image-pull-client-id", - SchedulerManagedIdentityClientId = "scheduler-client-id", - }; - options.AddActivity("RemoteHello"); - FakeOnDemandSandboxActivitiesClient client = new() { TransientDeclarationFailures = 1 }; - OnDemandSandboxActivityDeclarationHostedService service = new( - client, - options, - runtimeOptions: null, - NullLogger.Instance); - - // Act - Func action = () => service.StartAsync(CancellationToken.None); - - // Assert - await action.Should().ThrowAsync() - .Where(exception => exception.StatusCode == StatusCode.Unavailable); - client.DeclarationAttempts.Should().Be(1); - client.Declarations.Should().BeEmpty(); - } - [Fact] public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_SendsLiveWorkerMetadataWithRegisteredActivities() { @@ -636,68 +509,6 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_StopAsy session.CompleteCalledWhileWriteActive.Should().BeFalse(); } - [Fact] - public async Task ExcludeOnDemandSandboxActivities_ConfiguresLocalWorkerExclusionFilterFromWorkerProfiles() - { - // Arrange - using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); - using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); - ServiceCollection services = new(); - services.Configure( - Options.DefaultName, - options => options.TaskHubName = TaskHub); - Mock mockBuilder = new(); - mockBuilder.Setup(builder => builder.Services).Returns(services); - mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); - - // Act - mockBuilder.Object.ExcludeOnDemandSandboxActivities(); - - await using ServiceProvider provider = services.BuildServiceProvider(); - DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); - - // Assert - filters.ExcludedActivities.Select(filter => filter.Name).Should().Contain("ConfiguredRemoteHello"); - filters.Activities.Should().BeEmpty(); - } - - [Fact] - public void ExcludeOnDemandSandboxActivities_DoesNotRegisterDeclarationHostedService() - { - // Arrange - using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); - using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); - ServiceCollection services = new(); - Mock mockBuilder = new(); - mockBuilder.Setup(builder => builder.Services).Returns(services); - mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); - - // Act - mockBuilder.Object.ExcludeOnDemandSandboxActivities(); - - // Assert - services.Should().NotContain(descriptor => descriptor.ServiceType == typeof(IHostedService)); - } - - [Fact] - public void ExcludeOnDemandSandboxActivities_WhenRunningInOnDemandSandboxWorker_Throws() - { - // Arrange - using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "Sandbox"); - ServiceCollection services = new(); - Mock mockBuilder = new(); - mockBuilder.Setup(builder => builder.Services).Returns(services); - mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); - - // Act - Action action = () => mockBuilder.Object.ExcludeOnDemandSandboxActivities(); - - // Assert - action.Should().Throw() - .WithMessage("On-demand sandbox activity exclusion is for normal app workers. DTS on-demand sandbox workers should use UseSandboxWorker instead."); - services.Should().NotContain(descriptor => descriptor.ServiceType == typeof(IHostedService)); - } - [Fact] public void OnDemandSandboxActivityDeclarationResolver_ResolveDeclarations_UsesWorkerProfileConfigure() { @@ -728,20 +539,6 @@ public void OnDemandSandboxActivityDeclarationResolver_ResolveDeclarations_UsesW declaration.Cmd.Should().BeEmpty(); } - [Fact] - public void OnDemandSandboxActivityDeclarationResolver_ResolveDeclaredActivityNames_UsesWorkerProfileConfigure() - { - // Arrange - int before = AnnotatedWorkerProfile.ConfigureCallCount; - - // Act - string[] activityNames = OnDemandSandboxActivityDeclarationResolver.ResolveDeclaredActivityNames(TaskHub); - - // Assert - activityNames.Should().Contain("ConfiguredRemoteHello"); - AnnotatedWorkerProfile.ConfigureCallCount.Should().BeGreaterThan(before); - } - [Fact] public async Task UseSandboxWorker_ConfiguresRegisteredActivityWorkerFilter() { @@ -766,7 +563,6 @@ public async Task UseSandboxWorker_ConfiguresRegisteredActivityWorkerFilter() // Assert filters.Activities.Select(filter => filter.Name).Should().Equal("RemoteHello"); - filters.ExcludedActivities.Should().BeEmpty(); filters.Orchestrations.Should().BeEmpty(); filters.Entities.Should().BeEmpty(); workerOptions.Concurrency.MaximumConcurrentActivityWorkItems.Should().Be(3); From 92343e3cc482006e9a862d117442dbacf7a0fe06 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 10 Jun 2026 14:46:01 -0700 Subject: [PATCH 51/81] Simplify on-demand sandbox sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/on-demand-sandbox/main-app/Program.cs | 56 +++---------------- 1 file changed, 9 insertions(+), 47 deletions(-) diff --git a/samples/on-demand-sandbox/main-app/Program.cs b/samples/on-demand-sandbox/main-app/Program.cs index 74f6b0ce..b3a2e9ed 100644 --- a/samples/on-demand-sandbox/main-app/Program.cs +++ b/samples/on-demand-sandbox/main-app/Program.cs @@ -9,7 +9,6 @@ using Microsoft.DurableTask.Samples.OnDemandSandbox.MainApp; using Microsoft.DurableTask.Worker; using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -19,7 +18,6 @@ HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); string endpoint = builder.Configuration["OnDemandSandboxSample:EndpointAddress"]!; string taskHub = builder.Configuration["OnDemandSandboxSample:TaskHubName"]!; -int orchestrationCount = GetOrchestrationCount(builder.Configuration); TokenCredential credential = new DefaultAzureCredential(); builder.Logging.AddSimpleConsole(options => { @@ -58,51 +56,15 @@ await sandboxActivitiesClient.EnableOnDemandSandboxActivitiesAsync(); DurableTaskClient client = host.Services.GetRequiredService(); -List instanceIds = new(orchestrationCount); -for (int index = 1; index <= orchestrationCount; index++) -{ - string input = orchestrationCount == 1 ? Input : $"{Input}-{index:D3}"; - string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( - OnDemandSandboxTaskNames.HelloOrchestrator, - input: input); - instanceIds.Add(instanceId); - Console.WriteLine($"Started orchestration {index}/{orchestrationCount}: {instanceId}"); -} - -List> completionTasks = new(orchestrationCount); -foreach (string instanceId in instanceIds) -{ - completionTasks.Add(client.WaitForInstanceCompletionAsync( - instanceId, - getInputsAndOutputs: true)); -} - -OrchestrationMetadata[] results = await Task.WhenAll(completionTasks); -int completedCount = 0; -for (int index = 0; index < results.Length; index++) -{ - OrchestrationMetadata? result = results[index]; - if (result?.RuntimeStatus == OrchestrationRuntimeStatus.Completed) - { - completedCount++; - } +string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( + OnDemandSandboxTaskNames.HelloOrchestrator, + input: Input); +Console.WriteLine($"Started orchestration: {instanceId}"); - Console.WriteLine($"Orchestration {index + 1}/{orchestrationCount}: {instanceIds[index]}"); - Console.WriteLine($"Runtime status: {result?.RuntimeStatus}"); - Console.WriteLine($"Output: {result?.SerializedOutput ?? ""}"); -} - -Console.WriteLine($"Completed orchestrations: {completedCount}/{orchestrationCount}"); +OrchestrationMetadata result = await client.WaitForInstanceCompletionAsync( + instanceId, + getInputsAndOutputs: true); +Console.WriteLine($"Runtime status: {result.RuntimeStatus}"); +Console.WriteLine($"Output: {result.SerializedOutput ?? ""}"); await host.StopAsync(); - -static int GetOrchestrationCount(IConfiguration configuration) -{ - string? configuredValue = configuration["OnDemandSandboxSample:OrchestrationCount"]; - if (int.TryParse(configuredValue, out int configuredCount) && configuredCount > 0) - { - return configuredCount; - } - - return 1; -} From d8529df5b9c8216b20ee37eb8c573cb65a9478c7 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 10 Jun 2026 15:10:07 -0700 Subject: [PATCH 52/81] Clean up on-demand sandbox extension surface Use extension-scoped log event IDs, remove the generated-client extension method, rename the worker gRPC adapter to transport, and split declaration and worker-message builders. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Client/OnDemandSandboxActivitiesClient.cs | 29 ++++- ...DemandSandboxActivitiesClientExtensions.cs | 59 --------- ...chedulerOnDemandSandboxWorkerExtensions.cs | 8 +- .../Worker/OnDemandSandbox/Logs.cs | 36 ++---- ...OnDemandSandboxActivitiesGrpcTransport.cs} | 12 +- ...emandSandboxActivityDeclarationBuilder.cs} | 115 +++--------------- ...emandSandboxActivityDeclarationResolver.cs | 4 +- ...ActivityWorkerRegistrationHostedService.cs | 16 +-- .../OnDemandSandboxWorkerMessageBuilder.cs | 98 +++++++++++++++ ...> OnDemandSandboxActivitiesClientTests.cs} | 19 +-- .../OnDemandSandboxActivitiesTests.cs | 58 ++++----- 11 files changed, 198 insertions(+), 256 deletions(-) delete mode 100644 src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientExtensions.cs rename src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/{OnDemandSandboxActivitiesClientAdapter.cs => OnDemandSandboxActivitiesGrpcTransport.cs} (94%) rename src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/{OnDemandSandboxActivityConfiguration.cs => OnDemandSandboxActivityDeclarationBuilder.cs} (62%) create mode 100644 src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs rename test/Extensions/AzureManagedOnDemandSandbox.Tests/{OnDemandSandboxActivitiesClientExtensionsTests.cs => OnDemandSandboxActivitiesClientTests.cs} (89%) diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs index 8932ccd5..1aed91e3 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs @@ -45,14 +45,14 @@ public async Task EnableOnDemandSandboxActivitiesAsync(CancellationToken cancell OnDemandSandboxActivityDeclarationResolver.ResolveDeclarations(this.taskHub); foreach (OnDemandSandboxOptions options in declarations) { - string[] activityNames = OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames); + string[] activityNames = OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames); if (activityNames.Length == 0) { continue; } Proto.OnDemandSandboxActivityDeclaration declaration = - OnDemandSandboxActivityConfiguration.BuildDeclaration(options, activityNames); + OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration(options, activityNames); using AsyncUnaryCall call = this.client.DeclareOnDemandSandboxActivitiesAsync( declaration, @@ -71,7 +71,30 @@ public async Task EnableOnDemandSandboxActivitiesAsync(CancellationToken cancell public Task RemoveOnDemandSandboxActivityDeclarationAsync( string workerProfileId, CancellationToken cancellation = default) - => this.client.RemoveOnDemandSandboxActivityDeclarationAsync(workerProfileId, cancellation); + { + string normalizedWorkerProfileId = string.IsNullOrWhiteSpace(workerProfileId) + ? throw new ArgumentException("Worker profile ID is required.", nameof(workerProfileId)) + : workerProfileId.Trim(); + + Proto.RemoveOnDemandSandboxActivityDeclarationRequest request = new() + { + WorkerProfileId = normalizedWorkerProfileId, + }; + + return this.RemoveOnDemandSandboxActivityDeclarationCoreAsync(request, cancellation); + } + + async Task RemoveOnDemandSandboxActivityDeclarationCoreAsync( + Proto.RemoveOnDemandSandboxActivityDeclarationRequest request, + CancellationToken cancellation) + { + using AsyncUnaryCall call = + this.client.RemoveOnDemandSandboxActivityDeclarationAsync( + request, + headers: this.CreateTaskHubHeaders(), + cancellationToken: cancellation); + await call.ResponseAsync.ConfigureAwait(false); + } Metadata? CreateTaskHubHeaders() => this.attachTaskHubMetadata ? new Metadata { { "taskhub", this.taskHub }, } diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientExtensions.cs b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientExtensions.cs deleted file mode 100644 index 0cc714cd..00000000 --- a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientExtensions.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Grpc.Core; -using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; - -namespace Microsoft.DurableTask.Client.AzureManaged; - -/// -/// Extension methods for the generated on-demand sandbox activities gRPC client. -/// -public static class OnDemandSandboxActivitiesClientExtensions -{ - /// - /// Removes an on-demand sandbox activity declaration for a worker profile using task hub metadata already configured on the gRPC channel. - /// - /// The generated on-demand sandbox activities gRPC client. - /// The worker profile ID whose declaration should be removed. - /// The cancellation token used to cancel the request. - /// A task that completes when DTS removes the declaration. - public static Task RemoveOnDemandSandboxActivityDeclarationAsync( - this Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client, - string workerProfileId, - CancellationToken cancellation = default) - { - return RemoveOnDemandSandboxActivityDeclarationCoreAsync( - client, - workerProfileId, - cancellation); - } - - static async Task RemoveOnDemandSandboxActivityDeclarationCoreAsync( - Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client, - string workerProfileId, - CancellationToken cancellation) - { - ArgumentNullException.ThrowIfNull(client); - ValidateRequired(workerProfileId, nameof(workerProfileId), "Worker profile ID is required."); - - Proto.RemoveOnDemandSandboxActivityDeclarationRequest request = new() - { - WorkerProfileId = workerProfileId, - }; - - using AsyncUnaryCall call = client.RemoveOnDemandSandboxActivityDeclarationAsync( - request, - headers: null, - cancellationToken: cancellation); - await call.ResponseAsync.ConfigureAwait(false); - } - - static void ValidateRequired(string value, string parameterName, string message) - { - if (string.IsNullOrWhiteSpace(value)) - { - throw new ArgumentException(message, parameterName); - } - } -} diff --git a/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs b/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs index c5c039fb..c2106155 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs @@ -104,7 +104,7 @@ static OnDemandSandboxActivityWorkerRegistrationHostedService CreateOnDemandSand DurableTaskWorkerWorkItemFilters filters = services.GetRequiredService>().Get(builderName); return new OnDemandSandboxActivityWorkerRegistrationHostedService( - CreateOnDemandSandboxActivitiesClient(services, builderName), + CreateOnDemandSandboxActivitiesTransport(services, builderName), options, ResolveActivityFilterNames(filters.Activities), loggerFactory.CreateLogger(), @@ -112,17 +112,17 @@ static OnDemandSandboxActivityWorkerRegistrationHostedService CreateOnDemandSand activityTracker); } - static OnDemandSandboxActivitiesClientAdapter CreateOnDemandSandboxActivitiesClient(IServiceProvider services, string builderName) + static OnDemandSandboxActivitiesGrpcTransport CreateOnDemandSandboxActivitiesTransport(IServiceProvider services, string builderName) { GrpcDurableTaskWorkerOptions options = services.GetRequiredService>().Get(builderName); if (options.CallInvoker is { } callInvoker) { - return new OnDemandSandboxActivitiesClientAdapter(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)); + return new OnDemandSandboxActivitiesGrpcTransport(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)); } if (options.Channel is { } channel) { - return new OnDemandSandboxActivitiesClientAdapter( + return new OnDemandSandboxActivitiesGrpcTransport( new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(channel.CreateCallInvoker()), attachTaskHubMetadata: false); } diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/Logs.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/Logs.cs index 487bdaeb..b3e9022b 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/Logs.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/Logs.cs @@ -12,74 +12,56 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; static partial class Logs { [LoggerMessage( - EventId = 1, - Level = LogLevel.Information, - Message = "No on-demand sandbox activities discovered for hub={Hub}; skipping declaration")] - public static partial void NoOnDemandSandboxActivitiesForDeclaration(ILogger logger, string hub); - - [LoggerMessage( - EventId = 2, - Level = LogLevel.Information, - Message = "On-demand sandbox activities declared hub={Hub} workerProfile={WorkerProfile} count={Count} image={Image}")] - public static partial void OnDemandSandboxActivitiesDeclared(ILogger logger, string hub, string workerProfile, int count, string image); - - [LoggerMessage( - EventId = 4, - Level = LogLevel.Error, - Message = "On-demand sandbox activity declaration failed hub={Hub}")] - public static partial void OnDemandSandboxActivityDeclarationFailed(ILogger logger, Exception exception, string hub); - - [LoggerMessage( - EventId = 5, + EventId = 700, Level = LogLevel.Information, Message = "No on-demand sandbox activities discovered for worker hub={Hub}; skipping live registration")] public static partial void NoOnDemandSandboxActivitiesForWorkerRegistration(ILogger logger, string hub); [LoggerMessage( - EventId = 6, + EventId = 701, Level = LogLevel.Information, Message = "On-demand sandbox activity worker registered hub={Hub} count={Count} substrate={Substrate} sandboxId={SandboxId}")] public static partial void OnDemandSandboxActivityWorkerRegistered( ILogger logger, string hub, int count, Proto.SubstrateKind substrate, string sandboxId); [LoggerMessage( - EventId = 7, + EventId = 702, Level = LogLevel.Error, Message = "On-demand sandbox activity worker registration stream failed hub={Hub}")] public static partial void OnDemandSandboxActivityWorkerRegistrationFailed(ILogger logger, Exception exception, string hub); [LoggerMessage( - EventId = 8, + EventId = 703, Level = LogLevel.Debug, Message = "Ignoring on-demand sandbox worker session completion failure during shutdown.")] public static partial void OnDemandSandboxWorkerSessionCompletionFailureIgnored(ILogger logger, Exception exception); [LoggerMessage( - EventId = 9, + EventId = 704, Level = LogLevel.Debug, Message = "Ignoring on-demand sandbox worker registration pump cancellation during shutdown.")] public static partial void OnDemandSandboxWorkerRegistrationPumpCancellationIgnored(ILogger logger, Exception exception); [LoggerMessage( - EventId = 10, + EventId = 705, Level = LogLevel.Debug, Message = "Ignoring on-demand sandbox worker registration pump failure during shutdown.")] public static partial void OnDemandSandboxWorkerRegistrationPumpFailureIgnored(ILogger logger, Exception exception); [LoggerMessage( - EventId = 11, + EventId = 706, Level = LogLevel.Debug, Message = "Ignoring on-demand sandbox worker session dispose failure during shutdown.")] public static partial void OnDemandSandboxWorkerSessionDisposeFailureIgnored(ILogger logger, Exception exception); [LoggerMessage( - EventId = 12, + EventId = 707, Level = LogLevel.Debug, Message = "Ignoring on-demand sandbox heartbeat pump cancellation after registration session completion.")] public static partial void OnDemandSandboxHeartbeatPumpCancellationIgnored(ILogger logger, Exception exception); [LoggerMessage( - EventId = 13, + EventId = 708, Level = LogLevel.Debug, Message = "Ignoring on-demand sandbox heartbeat pump failure after registration session completion.")] public static partial void OnDemandSandboxHeartbeatPumpFailureIgnored(ILogger logger, Exception exception); diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivitiesClientAdapter.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs similarity index 94% rename from src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivitiesClientAdapter.cs rename to src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs index 099d1029..b8a5f492 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivitiesClientAdapter.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs @@ -7,9 +7,9 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; /// -/// Client abstraction for the on-demand sandbox activities gRPC service. +/// Transport abstraction for the on-demand sandbox activities gRPC service. /// -interface IOnDemandSandboxActivitiesClient +interface IOnDemandSandboxActivitiesTransport { /// /// Declares on-demand sandbox activities to DTS. @@ -58,19 +58,19 @@ interface IOnDemandSandboxActivityWorkerSession : IAsyncDisposable } /// -/// gRPC-backed implementation of . +/// gRPC-backed implementation of . /// -sealed class OnDemandSandboxActivitiesClientAdapter : IOnDemandSandboxActivitiesClient +sealed class OnDemandSandboxActivitiesGrpcTransport : IOnDemandSandboxActivitiesTransport { readonly Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client; readonly bool attachTaskHubMetadata; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The generated on-demand sandbox activities gRPC client. /// True to add per-call task hub metadata when the underlying channel does not already do so. - public OnDemandSandboxActivitiesClientAdapter( + public OnDemandSandboxActivitiesGrpcTransport( Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client, bool attachTaskHubMetadata = true) { diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs similarity index 62% rename from src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs rename to src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs index 693291c5..a15dc4f8 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityConfiguration.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs @@ -7,9 +7,9 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; /// -/// Builds and normalizes on-demand sandbox activity protocol messages. +/// Builds and normalizes on-demand sandbox activity declaration protocol messages. /// -static class OnDemandSandboxActivityConfiguration +static class OnDemandSandboxActivityDeclarationBuilder { /// /// Resolves configured activity names for on-demand sandbox activity execution. @@ -31,20 +31,22 @@ public static string[] ResolveActivityNames(IEnumerable configuredNames) /// The on-demand sandbox options. /// The activity names included in the declaration. /// The declaration protocol message. - public static Proto.OnDemandSandboxActivityDeclaration BuildDeclaration(OnDemandSandboxOptions options, IReadOnlyCollection activityNames) + public static Proto.OnDemandSandboxActivityDeclaration BuildDeclaration( + OnDemandSandboxOptions options, + IReadOnlyCollection activityNames) { Check.NotNull(options); Check.NotNull(activityNames); - ValidateTaskHub(options.TaskHub, "On-demand sandbox activity declaration requires a task hub name."); - + _ = NormalizeRequired(options.TaskHub, "On-demand sandbox activity declaration requires a task hub name."); if (activityNames.Count == 0) { throw new InvalidOperationException("On-demand sandbox activity declaration requires at least one activity name."); } - string workerProfileId = NormalizeWorkerProfileId(options.WorkerProfileId, "On-demand sandbox activity declaration requires a worker profile ID."); - + string workerProfileId = NormalizeWorkerProfileId( + options.WorkerProfileId, + "On-demand sandbox activity declaration requires a worker profile ID."); if (options.MaxConcurrentActivities <= 0) { throw new InvalidOperationException("On-demand sandbox activity max concurrent activities must be greater than zero."); @@ -70,65 +72,19 @@ public static Proto.OnDemandSandboxActivityDeclaration BuildDeclaration(OnDemand return declaration; } - /// - /// Builds the initial on-demand sandbox activity worker registration message. - /// - /// The on-demand sandbox options. - /// The activity handlers registered by the worker process. - /// The worker start protocol message. - public static Proto.OnDemandSandboxActivityWorkerMessage BuildWorkerStart( - OnDemandSandboxWorkerRuntimeOptions options, - IReadOnlyCollection registeredActivityNames) + internal static string NormalizeWorkerProfileId(string value, string errorMessage) { - Check.NotNull(options); - Check.NotNull(registeredActivityNames); - - ValidateTaskHub(options.TaskHub, "On-demand sandbox activity worker registration requires a task hub name."); - string[] activityNames = ResolveActivityNames(registeredActivityNames); - if (activityNames.Length == 0) - { - throw new InvalidOperationException("On-demand sandbox activity worker registration requires at least one registered activity."); - } - - if (options.MaxConcurrentActivities <= 0) - { - throw new InvalidOperationException("On-demand sandbox activity worker max concurrent activities must be greater than zero."); - } - - string workerProfileId = NormalizeWorkerProfileId(options.WorkerProfileId, "On-demand sandbox activity worker registration requires a worker profile ID."); - - Proto.OnDemandSandboxActivityWorkerStart start = new() - { - TaskHub = options.TaskHub, - WorkerProfileId = workerProfileId, - MaxActivitiesCount = options.MaxConcurrentActivities, - Substrate = GetSubstrateFromEnvironment(), - DtsSandboxIdentifier = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID") ?? string.Empty, - }; - start.ActivityNames.AddRange(activityNames); - - return new Proto.OnDemandSandboxActivityWorkerMessage { Start = start }; + return NormalizeRequired(value, errorMessage); } - /// - /// Builds an on-demand sandbox activity worker heartbeat message. - /// - /// The number of activities currently executing. - /// The heartbeat protocol message. - public static Proto.OnDemandSandboxActivityWorkerMessage BuildWorkerHeartbeat(int activeActivitiesCount) + internal static string NormalizeRequired(string value, string errorMessage) { - if (activeActivitiesCount < 0) + if (string.IsNullOrWhiteSpace(value)) { - throw new InvalidOperationException("On-demand sandbox activity worker active activity count cannot be negative."); + throw new InvalidOperationException(errorMessage); } - return new Proto.OnDemandSandboxActivityWorkerMessage - { - Heartbeat = new Proto.OnDemandSandboxActivityWorkerHeartbeat - { - ActiveActivitiesCount = activeActivitiesCount, - }, - }; + return value.Trim(); } static Proto.OnDemandSandboxActivityImage BuildImage(OnDemandSandboxOptions options) @@ -160,47 +116,6 @@ static Proto.OnDemandSandboxActivityResources BuildResources(OnDemandSandboxOpti }; } - static Proto.SubstrateKind GetSubstrateFromEnvironment() - { - string? substrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); - if (substrate is null) - { - return Proto.SubstrateKind.Unspecified; - } - - if (substrate.Equals("Sandbox", StringComparison.OrdinalIgnoreCase)) - { - return Proto.SubstrateKind.Sandbox; - } - - if (substrate.Equals("AcaSessionPool", StringComparison.OrdinalIgnoreCase)) - { - return Proto.SubstrateKind.AcaSessionPool; - } - - return Proto.SubstrateKind.Unspecified; - } - - static void ValidateTaskHub(string value, string errorMessage) - { - _ = NormalizeRequired(value, errorMessage); - } - - static string NormalizeWorkerProfileId(string value, string errorMessage) - { - return NormalizeRequired(value, errorMessage); - } - - static string NormalizeRequired(string value, string errorMessage) - { - if (string.IsNullOrWhiteSpace(value)) - { - throw new InvalidOperationException(errorMessage); - } - - return value.Trim(); - } - static string NormalizeCpu(string value) { string normalized = NormalizeRequired(value, "On-demand sandbox activity declaration requires CPU resources."); diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs index 237890cd..efcb20fa 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs @@ -26,7 +26,7 @@ public static IReadOnlyList ResolveDeclarations(string t OnDemandSandboxOptions[] declarations = Profiles.Value .Select(profile => CreateOptions(normalizedTaskHub, profile)) - .Where(static options => OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames).Length > 0) + .Where(static options => OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames).Length > 0) .ToArray(); ValidateActivityOwnership(declarations); @@ -111,7 +111,7 @@ static void ValidateActivityOwnership(IEnumerable declar Dictionary activityOwners = new(StringComparer.Ordinal); foreach (OnDemandSandboxOptions declaration in declarations) { - foreach (string activityName in OnDemandSandboxActivityConfiguration.ResolveActivityNames(declaration.ActivityNames)) + foreach (string activityName in OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(declaration.ActivityNames)) { if (activityOwners.TryGetValue(activityName, out string? existingProfile) && !string.Equals(existingProfile, declaration.WorkerProfileId, StringComparison.Ordinal)) diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs index ea56755a..ac79e5ea 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs @@ -15,7 +15,7 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; sealed class OnDemandSandboxActivityWorkerRegistrationHostedService : IHostedService, IAsyncDisposable { readonly object sync = new(); - readonly IOnDemandSandboxActivitiesClient client; + readonly IOnDemandSandboxActivitiesTransport transport; readonly OnDemandSandboxWorkerRuntimeOptions options; readonly IReadOnlyCollection registeredActivityNames; readonly ILogger logger; @@ -30,7 +30,7 @@ sealed class OnDemandSandboxActivityWorkerRegistrationHostedService : IHostedSer /// /// Initializes a new instance of the class. /// - /// The on-demand sandbox activities client. + /// The on-demand sandbox activities transport. /// The on-demand sandbox worker runtime options. /// The activity handlers registered by this worker process. /// The logger. @@ -38,7 +38,7 @@ sealed class OnDemandSandboxActivityWorkerRegistrationHostedService : IHostedSer /// The optional activity tracker used to report live in-flight activity count. /// The optional random source used to jitter reconnect delays. public OnDemandSandboxActivityWorkerRegistrationHostedService( - IOnDemandSandboxActivitiesClient client, + IOnDemandSandboxActivitiesTransport transport, OnDemandSandboxWorkerRuntimeOptions options, IReadOnlyCollection registeredActivityNames, ILogger logger, @@ -46,7 +46,7 @@ public OnDemandSandboxActivityWorkerRegistrationHostedService( OnDemandSandboxActivityTracker? activityTracker = null, Random? reconnectJitter = null) { - this.client = Check.NotNull(client); + this.transport = Check.NotNull(transport); this.options = Check.NotNull(options); this.registeredActivityNames = Check.NotNull(registeredActivityNames); this.logger = Check.NotNull(logger); @@ -64,7 +64,7 @@ public Task StartAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - string[] activityNames = OnDemandSandboxActivityConfiguration.ResolveActivityNames(this.registeredActivityNames); + string[] activityNames = OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(this.registeredActivityNames); if (activityNames.Length == 0) { Logs.NoOnDemandSandboxActivitiesForWorkerRegistration(this.logger, this.options.TaskHub); @@ -202,10 +202,10 @@ async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancell IOnDemandSandboxActivityWorkerSession? registrationSession = null; try { - registrationSession = this.client.OpenOnDemandSandboxActivityWorkerSession(this.options.TaskHub, cancellationToken); + registrationSession = this.transport.OpenOnDemandSandboxActivityWorkerSession(this.options.TaskHub, cancellationToken); this.SetCurrentSession(registrationSession); - Proto.OnDemandSandboxActivityWorkerMessage startMessage = OnDemandSandboxActivityConfiguration.BuildWorkerStart(this.options, this.registeredActivityNames); + Proto.OnDemandSandboxActivityWorkerMessage startMessage = OnDemandSandboxWorkerMessageBuilder.BuildWorkerStart(this.options, this.registeredActivityNames); await this.WriteSessionMessageAsync(registrationSession, startMessage, cancellationToken).ConfigureAwait(false); Logs.OnDemandSandboxActivityWorkerRegistered( this.logger, @@ -329,7 +329,7 @@ async Task PumpHeartbeatsAsync( int activeActivitiesCount = this.activityTracker?.InFlightCount ?? 0; await this.WriteSessionMessageAsync( registrationSession, - OnDemandSandboxActivityConfiguration.BuildWorkerHeartbeat(activeActivitiesCount), + OnDemandSandboxWorkerMessageBuilder.BuildWorkerHeartbeat(activeActivitiesCount), cancellationToken).ConfigureAwait(false); } } diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs new file mode 100644 index 00000000..b7632785 --- /dev/null +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; + +namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; + +/// +/// Builds on-demand sandbox activity worker registration protocol messages. +/// +static class OnDemandSandboxWorkerMessageBuilder +{ + /// + /// Builds the initial on-demand sandbox activity worker registration message. + /// + /// The on-demand sandbox options. + /// The activity handlers registered by the worker process. + /// The worker start protocol message. + public static Proto.OnDemandSandboxActivityWorkerMessage BuildWorkerStart( + OnDemandSandboxWorkerRuntimeOptions options, + IReadOnlyCollection registeredActivityNames) + { + Check.NotNull(options); + Check.NotNull(registeredActivityNames); + + _ = OnDemandSandboxActivityDeclarationBuilder.NormalizeRequired( + options.TaskHub, + "On-demand sandbox activity worker registration requires a task hub name."); + string[] activityNames = OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(registeredActivityNames); + if (activityNames.Length == 0) + { + throw new InvalidOperationException("On-demand sandbox activity worker registration requires at least one registered activity."); + } + + if (options.MaxConcurrentActivities <= 0) + { + throw new InvalidOperationException("On-demand sandbox activity worker max activity count must be greater than zero."); + } + + string workerProfileId = OnDemandSandboxActivityDeclarationBuilder.NormalizeWorkerProfileId( + options.WorkerProfileId, + "On-demand sandbox activity worker registration requires a worker profile ID."); + + Proto.OnDemandSandboxActivityWorkerStart start = new() + { + TaskHub = options.TaskHub, + WorkerProfileId = workerProfileId, + MaxActivitiesCount = options.MaxConcurrentActivities, + Substrate = GetSubstrateFromEnvironment(), + DtsSandboxIdentifier = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID") ?? string.Empty, + }; + start.ActivityNames.AddRange(activityNames); + + return new Proto.OnDemandSandboxActivityWorkerMessage { Start = start }; + } + + /// + /// Builds an on-demand sandbox activity worker heartbeat message. + /// + /// The number of activities currently executing. + /// The heartbeat protocol message. + public static Proto.OnDemandSandboxActivityWorkerMessage BuildWorkerHeartbeat(int activeActivitiesCount) + { + if (activeActivitiesCount < 0) + { + throw new InvalidOperationException("On-demand sandbox activity worker active activity count cannot be negative."); + } + + return new Proto.OnDemandSandboxActivityWorkerMessage + { + Heartbeat = new Proto.OnDemandSandboxActivityWorkerHeartbeat + { + ActiveActivitiesCount = activeActivitiesCount, + }, + }; + } + + static Proto.SubstrateKind GetSubstrateFromEnvironment() + { + string? substrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); + if (substrate is null) + { + return Proto.SubstrateKind.Unspecified; + } + + if (substrate.Equals("Sandbox", StringComparison.OrdinalIgnoreCase)) + { + return Proto.SubstrateKind.Sandbox; + } + + if (substrate.Equals("AcaSessionPool", StringComparison.OrdinalIgnoreCase)) + { + return Proto.SubstrateKind.AcaSessionPool; + } + + return Proto.SubstrateKind.Unspecified; + } +} diff --git a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientExtensionsTests.cs b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientTests.cs similarity index 89% rename from test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientExtensionsTests.cs rename to test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientTests.cs index 7b8902a5..0b14c2c0 100644 --- a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientExtensionsTests.cs +++ b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientTests.cs @@ -12,7 +12,7 @@ namespace Microsoft.DurableTask.Client.AzureManaged.Tests; -public class OnDemandSandboxActivitiesClientExtensionsTests +public class OnDemandSandboxActivitiesClientTests { [Fact] public async Task AddDurableTaskSchedulerOnDemandSandboxActivitiesClient_UsesConfiguredDurableTaskClientInvoker() @@ -60,23 +60,6 @@ public async Task EnableOnDemandSandboxActivitiesAsync_SendsWorkerProfileDeclara callInvoker.UnaryDisposeCount.Should().BeGreaterThan(0); } - [Fact] - public async Task RemoveOnDemandSandboxActivityDeclarationAsync_SendsRequest() - { - // Arrange - RecordingOnDemandSandboxLogCallInvoker callInvoker = new(); - OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client = new(callInvoker); - - // Act - await client.RemoveOnDemandSandboxActivityDeclarationAsync("default"); - - // Assert - callInvoker.RemoveRequest.Should().NotBeNull(); - callInvoker.RemoveRequest!.WorkerProfileId.Should().Be("default"); - callInvoker.RemoveHeaders.Should().NotContain(header => header.Key == "taskhub"); - callInvoker.UnaryDisposeCount.Should().Be(1); - } - sealed class RecordingOnDemandSandboxLogCallInvoker : CallInvoker { public List DeclareRequests { get; } = []; diff --git a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs index 0ff3ec8f..5c21321c 100644 --- a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs +++ b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs @@ -63,7 +63,7 @@ public void OnDemandSandboxDeclarationContract_ExposesProfileAddActivityOnly() [InlineData("500m", "1024Mi")] [InlineData("0.5", "1Gi")] [InlineData("2", "2048")] - public void OnDemandSandboxActivityConfiguration_BuildDeclaration_AcceptsAdcResourceQuantities( + public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_AcceptsAdcResourceQuantities( string cpu, string memory) { @@ -73,9 +73,9 @@ public void OnDemandSandboxActivityConfiguration_BuildDeclaration_AcceptsAdcReso options.Memory = memory; // Act - OnDemandSandboxActivityDeclaration declaration = OnDemandSandboxActivityConfiguration.BuildDeclaration( + OnDemandSandboxActivityDeclaration declaration = OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( options, - OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames)); + OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); // Assert declaration.Resources.Cpu.Should().Be(cpu); @@ -89,7 +89,7 @@ public void OnDemandSandboxActivityConfiguration_BuildDeclaration_AcceptsAdcReso [InlineData("500m", "0", "memory")] [InlineData("500m", "0Mi", "memory")] [InlineData("500m", "500m", "memory")] - public void OnDemandSandboxActivityConfiguration_BuildDeclaration_RejectsInvalidAdcResourceQuantities( + public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_RejectsInvalidAdcResourceQuantities( string cpu, string memory, string expectedMessage) @@ -100,9 +100,9 @@ public void OnDemandSandboxActivityConfiguration_BuildDeclaration_RejectsInvalid options.Memory = memory; // Act - Action action = () => OnDemandSandboxActivityConfiguration.BuildDeclaration( + Action action = () => OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( options, - OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames)); + OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); // Assert action.Should().Throw() @@ -110,16 +110,16 @@ public void OnDemandSandboxActivityConfiguration_BuildDeclaration_RejectsInvalid } [Fact] - public void OnDemandSandboxActivityConfiguration_BuildDeclaration_RequiresSchedulerManagedIdentityClientId() + public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_RequiresSchedulerManagedIdentityClientId() { // Arrange OnDemandSandboxOptions options = CreateDeclarationOptions(); options.SchedulerManagedIdentityClientId = " "; // Act - Action action = () => OnDemandSandboxActivityConfiguration.BuildDeclaration( + Action action = () => OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( options, - OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames)); + OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); // Assert action.Should().Throw() @@ -127,16 +127,16 @@ public void OnDemandSandboxActivityConfiguration_BuildDeclaration_RequiresSchedu } [Fact] - public void OnDemandSandboxActivityConfiguration_BuildDeclaration_RequiresImagePullManagedIdentityClientId() + public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_RequiresImagePullManagedIdentityClientId() { // Arrange OnDemandSandboxOptions options = CreateDeclarationOptions(); options.ImagePullManagedIdentityClientId = " "; // Act - Action action = () => OnDemandSandboxActivityConfiguration.BuildDeclaration( + Action action = () => OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( options, - OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames)); + OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); // Assert action.Should().Throw() @@ -144,11 +144,11 @@ public void OnDemandSandboxActivityConfiguration_BuildDeclaration_RequiresImageP } [Fact] - public async Task OnDemandSandboxActivitiesClientAdapter_SendsTaskHubMetadata() + public async Task OnDemandSandboxActivitiesGrpcTransport_SendsTaskHubMetadata() { // Arrange RecordingOnDemandSandboxActivitiesCallInvoker callInvoker = new(); - OnDemandSandboxActivitiesClientAdapter adapter = new(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)); + OnDemandSandboxActivitiesGrpcTransport transport = new(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)); OnDemandSandboxActivityDeclaration declaration = new() { WorkerProfileId = "profile-a", @@ -166,8 +166,8 @@ public async Task OnDemandSandboxActivitiesClientAdapter_SendsTaskHubMetadata() declaration.ActivityNames.Add("RemoteHello"); // Act - await adapter.DeclareOnDemandSandboxActivitiesAsync(declaration, TaskHub, CancellationToken.None); - await using IOnDemandSandboxActivityWorkerSession session = adapter.OpenOnDemandSandboxActivityWorkerSession( + await transport.DeclareOnDemandSandboxActivitiesAsync(declaration, TaskHub, CancellationToken.None); + await using IOnDemandSandboxActivityWorkerSession session = transport.OpenOnDemandSandboxActivityWorkerSession( TaskHub, CancellationToken.None); @@ -177,11 +177,11 @@ public async Task OnDemandSandboxActivitiesClientAdapter_SendsTaskHubMetadata() } [Fact] - public async Task OnDemandSandboxActivitiesClientAdapter_CanRelyOnChannelTaskHubMetadata() + public async Task OnDemandSandboxActivitiesGrpcTransport_CanRelyOnChannelTaskHubMetadata() { // Arrange RecordingOnDemandSandboxActivitiesCallInvoker callInvoker = new(); - OnDemandSandboxActivitiesClientAdapter adapter = new( + OnDemandSandboxActivitiesGrpcTransport transport = new( new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker), attachTaskHubMetadata: false); OnDemandSandboxActivityDeclaration declaration = new() @@ -201,8 +201,8 @@ public async Task OnDemandSandboxActivitiesClientAdapter_CanRelyOnChannelTaskHub declaration.ActivityNames.Add("RemoteHello"); // Act - await adapter.DeclareOnDemandSandboxActivitiesAsync(declaration, TaskHub, CancellationToken.None); - await using IOnDemandSandboxActivityWorkerSession session = adapter.OpenOnDemandSandboxActivityWorkerSession( + await transport.DeclareOnDemandSandboxActivitiesAsync(declaration, TaskHub, CancellationToken.None); + await using IOnDemandSandboxActivityWorkerSession session = transport.OpenOnDemandSandboxActivityWorkerSession( TaskHub, CancellationToken.None); @@ -230,7 +230,7 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_SendsLi MaxConcurrentActivities = 3, HeartbeatInterval = TimeSpan.FromDays(1), }; - FakeOnDemandSandboxActivitiesClient client = new(); + FakeOnDemandSandboxActivitiesTransport client = new(); OnDemandSandboxActivityWorkerRegistrationHostedService service = new( client, options, @@ -300,7 +300,7 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_SendsHe HeartbeatInterval = TimeSpan.FromMilliseconds(10), }; - FakeOnDemandSandboxActivitiesClient client = new(); + FakeOnDemandSandboxActivitiesTransport client = new(); OnDemandSandboxActivityTracker activityTracker = new(); activityTracker.NotifyActivityStarted(); activityTracker.NotifyActivityStarted(); @@ -341,7 +341,7 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_Reopens FakeOnDemandSandboxActivityWorkerSession failedSession = new() { ThrowOnWriteAttempt = 2 }; FakeOnDemandSandboxActivityWorkerSession recoveredSession = new(); - FakeOnDemandSandboxActivitiesClient client = new(); + FakeOnDemandSandboxActivitiesTransport client = new(); client.QueueSession(failedSession); client.QueueSession(recoveredSession); @@ -380,7 +380,7 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_Reopens FakeOnDemandSandboxActivityWorkerSession failedSession = new(); FakeOnDemandSandboxActivityWorkerSession recoveredSession = new(); - FakeOnDemandSandboxActivitiesClient client = new(); + FakeOnDemandSandboxActivitiesTransport client = new(); client.QueueSession(failedSession); client.QueueSession(recoveredSession); @@ -448,7 +448,7 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_Applies FakeOnDemandSandboxActivityWorkerSession failedSession = new() { ThrowOnWriteAttempt = 2 }; FakeOnDemandSandboxActivityWorkerSession recoveredSession = new(); - FakeOnDemandSandboxActivitiesClient client = new(); + FakeOnDemandSandboxActivitiesTransport client = new(); client.QueueSession(failedSession); client.QueueSession(recoveredSession); @@ -483,7 +483,7 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_StopAsy }; FakeOnDemandSandboxActivityWorkerSession session = new() { BlockWriteAttempt = 2 }; - FakeOnDemandSandboxActivitiesClient client = new(); + FakeOnDemandSandboxActivitiesTransport client = new(); client.QueueSession(session); OnDemandSandboxActivityWorkerRegistrationHostedService service = new( @@ -521,9 +521,9 @@ public void OnDemandSandboxActivityDeclarationResolver_ResolveDeclarations_UsesW // Act OnDemandSandboxOptions options = OnDemandSandboxActivityDeclarationResolver.ResolveDeclarations(TaskHub) .Single(options => options.WorkerProfileId == "annotated-profile"); - OnDemandSandboxActivityDeclaration declaration = OnDemandSandboxActivityConfiguration.BuildDeclaration( + OnDemandSandboxActivityDeclaration declaration = OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( options, - OnDemandSandboxActivityConfiguration.ResolveActivityNames(options.ActivityNames)); + OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); // Assert declaration.WorkerProfileId.Should().Be("annotated-profile"); @@ -766,7 +766,7 @@ public void Configure(OnDemandSandboxOptions options) } } - sealed class FakeOnDemandSandboxActivitiesClient : IOnDemandSandboxActivitiesClient + sealed class FakeOnDemandSandboxActivitiesTransport : IOnDemandSandboxActivitiesTransport { readonly Queue queuedSessions = new(); From 95a67dc1bd8894ff8072d365bb784f811384d2a7 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 10 Jun 2026 15:23:15 -0700 Subject: [PATCH 53/81] Share on-demand sandbox gRPC transport Route both the declarer client and sandbox worker registration through the same internal on-demand sandbox gRPC transport, matching the Python SDK structure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Client/OnDemandSandboxActivitiesClient.cs | 51 +++++-------------- ...vitiesClientServiceCollectionExtensions.cs | 15 +++--- ...chedulerOnDemandSandboxWorkerExtensions.cs | 1 + .../OnDemandSandboxActivitiesGrpcTransport.cs | 40 +++++++++++++-- ...ActivityWorkerRegistrationHostedService.cs | 1 + .../OnDemandSandboxActivitiesClientTests.cs | 3 +- .../OnDemandSandboxActivitiesTests.cs | 9 ++++ 7 files changed, 72 insertions(+), 48 deletions(-) rename src/Extensions/AzureManagedOnDemandSandbox/{Worker/OnDemandSandbox => }/OnDemandSandboxActivitiesGrpcTransport.cs (77%) diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs index 1aed91e3..bd8ce5c1 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Grpc.Core; +using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; using Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; @@ -12,26 +12,22 @@ namespace Microsoft.DurableTask.Client.AzureManaged; /// public sealed class OnDemandSandboxActivitiesClient { - readonly Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client; + readonly IOnDemandSandboxActivitiesTransport transport; readonly string taskHub; - readonly bool attachTaskHubMetadata; /// /// Initializes a new instance of the class. /// - /// The generated gRPC client used to call DTS on-demand sandbox management operations. + /// The transport used to call DTS on-demand sandbox management operations. /// The task hub whose declarations should be sent to DTS. - /// True to add per-call task hub metadata when the underlying channel does not already do so. internal OnDemandSandboxActivitiesClient( - Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client, - string taskHub, - bool attachTaskHubMetadata = true) + IOnDemandSandboxActivitiesTransport transport, + string taskHub) { - this.client = client; + this.transport = Check.NotNull(transport); this.taskHub = string.IsNullOrWhiteSpace(taskHub) ? throw new ArgumentException("Task hub name is required.", nameof(taskHub)) : taskHub.Trim(); - this.attachTaskHubMetadata = attachTaskHubMetadata; } /// @@ -53,12 +49,11 @@ public async Task EnableOnDemandSandboxActivitiesAsync(CancellationToken cancell Proto.OnDemandSandboxActivityDeclaration declaration = OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration(options, activityNames); - using AsyncUnaryCall call = - this.client.DeclareOnDemandSandboxActivitiesAsync( + await this.transport.DeclareOnDemandSandboxActivitiesAsync( declaration, - headers: this.CreateTaskHubHeaders(), - cancellationToken: cancellation); - await call.ResponseAsync.ConfigureAwait(false); + this.taskHub, + cancellation) + .ConfigureAwait(false); } } @@ -76,27 +71,9 @@ public Task RemoveOnDemandSandboxActivityDeclarationAsync( ? throw new ArgumentException("Worker profile ID is required.", nameof(workerProfileId)) : workerProfileId.Trim(); - Proto.RemoveOnDemandSandboxActivityDeclarationRequest request = new() - { - WorkerProfileId = normalizedWorkerProfileId, - }; - - return this.RemoveOnDemandSandboxActivityDeclarationCoreAsync(request, cancellation); - } - - async Task RemoveOnDemandSandboxActivityDeclarationCoreAsync( - Proto.RemoveOnDemandSandboxActivityDeclarationRequest request, - CancellationToken cancellation) - { - using AsyncUnaryCall call = - this.client.RemoveOnDemandSandboxActivityDeclarationAsync( - request, - headers: this.CreateTaskHubHeaders(), - cancellationToken: cancellation); - await call.ResponseAsync.ConfigureAwait(false); + return this.transport.RemoveOnDemandSandboxActivityDeclarationAsync( + normalizedWorkerProfileId, + this.taskHub, + cancellation); } - - Metadata? CreateTaskHubHeaders() => this.attachTaskHubMetadata - ? new Metadata { { "taskhub", this.taskHub }, } - : null; } diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs index 019b2d95..1156ecdc 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Grpc.Net.Client; +using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; using Microsoft.DurableTask.Client.Grpc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -47,17 +48,19 @@ public static IServiceCollection AddDurableTaskSchedulerOnDemandSandboxActivitie if (options.CallInvoker is { } callInvoker) { return new OnDemandSandboxActivitiesClient( - new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker), - schedulerOptions.TaskHubName, - attachTaskHubMetadata: false); + new OnDemandSandboxActivitiesGrpcTransport( + new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker), + attachTaskHubMetadata: false), + schedulerOptions.TaskHubName); } if (options.Channel is GrpcChannel channel) { return new OnDemandSandboxActivitiesClient( - new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(channel.CreateCallInvoker()), - schedulerOptions.TaskHubName, - attachTaskHubMetadata: false); + new OnDemandSandboxActivitiesGrpcTransport( + new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(channel.CreateCallInvoker()), + attachTaskHubMetadata: false), + schedulerOptions.TaskHubName); } throw new InvalidOperationException("DTS on-demand sandbox activity management requires a configured Durable Task Scheduler client."); diff --git a/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs b/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs index c2106155..5118cf66 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs @@ -6,6 +6,7 @@ using Azure.Core; using Azure.Identity; using Grpc.Net.Client; +using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; using Microsoft.DurableTask.Protobuf.OnDemandSandbox; using Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; using Microsoft.DurableTask.Worker.Grpc; diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs b/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs similarity index 77% rename from src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs rename to src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs index b8a5f492..c67998ca 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs @@ -4,7 +4,7 @@ using Grpc.Core; using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; -namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; +namespace Microsoft.DurableTask.AzureManaged.OnDemandSandbox; /// /// Transport abstraction for the on-demand sandbox activities gRPC service. @@ -23,6 +23,18 @@ interface IOnDemandSandboxActivitiesTransport string taskHub, CancellationToken cancellationToken); + /// + /// Removes an on-demand sandbox activity declaration from DTS. + /// + /// The worker profile ID whose declaration should be removed. + /// The task hub that owns the declaration. + /// The cancellation token. + /// The removal result. + Task RemoveOnDemandSandboxActivityDeclarationAsync( + string workerProfileId, + string taskHub, + CancellationToken cancellationToken); + /// /// Opens an on-demand sandbox activity worker registration session. /// @@ -84,11 +96,31 @@ public OnDemandSandboxActivitiesGrpcTransport( string taskHub, CancellationToken cancellationToken) { - return await this.client.DeclareOnDemandSandboxActivitiesAsync( + using AsyncUnaryCall call = + this.client.DeclareOnDemandSandboxActivitiesAsync( declaration, headers: this.CreateTaskHubHeaders(taskHub), - cancellationToken: cancellationToken) - .ResponseAsync.ConfigureAwait(false); + cancellationToken: cancellationToken); + return await call.ResponseAsync.ConfigureAwait(false); + } + + /// + public async Task RemoveOnDemandSandboxActivityDeclarationAsync( + string workerProfileId, + string taskHub, + CancellationToken cancellationToken) + { + Proto.RemoveOnDemandSandboxActivityDeclarationRequest request = new() + { + WorkerProfileId = workerProfileId, + }; + + using AsyncUnaryCall call = + this.client.RemoveOnDemandSandboxActivityDeclarationAsync( + request, + headers: this.CreateTaskHubHeaders(taskHub), + cancellationToken: cancellationToken); + return await call.ResponseAsync.ConfigureAwait(false); } /// diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs index ac79e5ea..00a1fe4d 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs @@ -3,6 +3,7 @@ using System.IO; using Grpc.Core; +using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; diff --git a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientTests.cs b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientTests.cs index 0b14c2c0..d99441ef 100644 --- a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientTests.cs +++ b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using Grpc.Core; using Microsoft.DurableTask.Client.Grpc; +using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; using Microsoft.DurableTask.Protobuf.OnDemandSandbox; using Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; using Microsoft.Extensions.DependencyInjection; @@ -43,7 +44,7 @@ public async Task EnableOnDemandSandboxActivitiesAsync_SendsWorkerProfileDeclara // Arrange RecordingOnDemandSandboxLogCallInvoker callInvoker = new(); OnDemandSandboxActivitiesClient client = new( - new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker), + new OnDemandSandboxActivitiesGrpcTransport(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)), "client-test-taskhub"); // Act diff --git a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs index 5c21321c..32c161d0 100644 --- a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs +++ b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs @@ -5,6 +5,7 @@ using Azure.Identity; using FluentAssertions; using Grpc.Core; +using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; using Microsoft.DurableTask.Protobuf.OnDemandSandbox; using Microsoft.DurableTask.Worker.AzureManaged; using Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; @@ -802,6 +803,14 @@ public Task DeclareOnDemandSandboxActi return Task.FromResult(new OnDemandSandboxActivityDeclarationResult()); } + public Task RemoveOnDemandSandboxActivityDeclarationAsync( + string workerProfileId, + string taskHub, + CancellationToken cancellationToken) + { + return Task.FromResult(new RemoveOnDemandSandboxActivityDeclarationResult()); + } + public IOnDemandSandboxActivityWorkerSession OpenOnDemandSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken) { this.SessionTaskHubs.Add(taskHub); From c60c2f295b295babf18eddf058882c57835f0862 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 10 Jun 2026 15:31:11 -0700 Subject: [PATCH 54/81] Move sandbox declaration types to shared namespace Keep on-demand sandbox declaration/profile configuration with the shared AzureManaged on-demand sandbox extension surface instead of the worker-runtime namespace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/on-demand-sandbox/main-app/WorkerProfiles.cs | 2 +- .../Client/OnDemandSandboxActivitiesClient.cs | 1 - .../{Worker/OnDemandSandbox => }/ISandboxWorkerProfile.cs | 2 +- .../OnDemandSandboxActivityDeclarationBuilder.cs | 2 +- .../OnDemandSandboxActivityDeclarationResolver.cs | 2 +- .../{Worker/OnDemandSandbox => }/OnDemandSandboxOptions.cs | 3 ++- .../OnDemandSandboxWorkerProfileAttribute.cs | 2 +- .../OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs | 1 + .../OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs | 2 ++ .../OnDemandSandboxActivitiesClientTests.cs | 3 +-- 10 files changed, 11 insertions(+), 9 deletions(-) rename src/Extensions/AzureManagedOnDemandSandbox/{Worker/OnDemandSandbox => }/ISandboxWorkerProfile.cs (86%) rename src/Extensions/AzureManagedOnDemandSandbox/{Worker/OnDemandSandbox => }/OnDemandSandboxActivityDeclarationBuilder.cs (99%) rename src/Extensions/AzureManagedOnDemandSandbox/{Worker/OnDemandSandbox => }/OnDemandSandboxActivityDeclarationResolver.cs (98%) rename src/Extensions/AzureManagedOnDemandSandbox/{Worker/OnDemandSandbox => }/OnDemandSandboxOptions.cs (97%) rename src/Extensions/AzureManagedOnDemandSandbox/{Worker/OnDemandSandbox => }/OnDemandSandboxWorkerProfileAttribute.cs (93%) diff --git a/samples/on-demand-sandbox/main-app/WorkerProfiles.cs b/samples/on-demand-sandbox/main-app/WorkerProfiles.cs index d6aa0a3a..b91596d7 100644 --- a/samples/on-demand-sandbox/main-app/WorkerProfiles.cs +++ b/samples/on-demand-sandbox/main-app/WorkerProfiles.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; +using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; using Microsoft.DurableTask.Samples.OnDemandSandbox.Shared; namespace Microsoft.DurableTask.Samples.OnDemandSandbox.MainApp; diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs index bd8ce5c1..22cade53 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; -using Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; namespace Microsoft.DurableTask.Client.AzureManaged; diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/ISandboxWorkerProfile.cs b/src/Extensions/AzureManagedOnDemandSandbox/ISandboxWorkerProfile.cs similarity index 86% rename from src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/ISandboxWorkerProfile.cs rename to src/Extensions/AzureManagedOnDemandSandbox/ISandboxWorkerProfile.cs index 569593a9..ac7b6b40 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/ISandboxWorkerProfile.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/ISandboxWorkerProfile.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; +namespace Microsoft.DurableTask.AzureManaged.OnDemandSandbox; /// /// Configures an on-demand sandbox worker profile declaration. diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs b/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs similarity index 99% rename from src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs rename to src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs index a15dc4f8..ae15a75d 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs @@ -4,7 +4,7 @@ using System.Globalization; using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; -namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; +namespace Microsoft.DurableTask.AzureManaged.OnDemandSandbox; /// /// Builds and normalizes on-demand sandbox activity declaration protocol messages. diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs b/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs similarity index 98% rename from src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs rename to src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs index efcb20fa..614b40ae 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs @@ -4,7 +4,7 @@ using System.Reflection; using System.Threading; -namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; +namespace Microsoft.DurableTask.AzureManaged.OnDemandSandbox; /// /// Resolves on-demand sandbox activity declarations from worker profile configuration. diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs b/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxOptions.cs similarity index 97% rename from src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs rename to src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxOptions.cs index 7a919c40..be38a51b 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxOptions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxOptions.cs @@ -2,8 +2,9 @@ // Licensed under the MIT License. using System.Reflection; +using Microsoft.DurableTask.Worker; -namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; +namespace Microsoft.DurableTask.AzureManaged.OnDemandSandbox; /// /// Options for declaring on-demand sandbox activities and the worker image DTS should start for them. diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs b/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs similarity index 93% rename from src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs rename to src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs index 5d58c917..c6159479 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; +namespace Microsoft.DurableTask.AzureManaged.OnDemandSandbox; /// /// Declares an on-demand sandbox worker profile that DTS can start for activities declared by the profile. diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs index b7632785..ac360510 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs index 373545a0..0a557f99 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs +++ b/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; + namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; /// diff --git a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientTests.cs b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientTests.cs index d99441ef..63f1b3d8 100644 --- a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientTests.cs +++ b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientTests.cs @@ -3,10 +3,9 @@ using FluentAssertions; using Grpc.Core; -using Microsoft.DurableTask.Client.Grpc; using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; +using Microsoft.DurableTask.Client.Grpc; using Microsoft.DurableTask.Protobuf.OnDemandSandbox; -using Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Xunit; From d3c342b240dfcbfa677531d0b4c25c84f0a9fb7e Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 10 Jun 2026 15:46:36 -0700 Subject: [PATCH 55/81] Split on-demand sandbox APIs into AzureManaged packages Move declarer/client APIs into Client.AzureManaged, worker runtime APIs into Worker.AzureManaged, and keep only internal transport/metadata helpers in shared AzureManaged sources. Remove the standalone on-demand sandbox extension project and split tests into existing client/worker AzureManaged test projects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + Microsoft.DurableTask.sln | 42 -- .../main-app/WorkerProfiles.cs | 2 +- .../main-app/main-app.csproj | 1 - .../remote-worker/remote-worker.csproj | 1 - .../AzureManaged/Client.AzureManaged.csproj | 1 + .../OnDemandSandbox}/ISandboxWorkerProfile.cs | 2 +- .../OnDemandSandboxActivitiesClient.cs | 2 +- ...vitiesClientServiceCollectionExtensions.cs | 2 +- ...DemandSandboxActivityDeclarationBuilder.cs | 18 +- ...emandSandboxActivityDeclarationResolver.cs | 2 +- .../OnDemandSandboxOptions.cs | 7 +- .../OnDemandSandboxWorkerProfileAttribute.cs | 2 +- .../AzureManagedOnDemandSandbox.csproj | 25 -- .../OnDemandSandboxActivitiesGrpcTransport.cs | 2 +- .../OnDemandSandboxActivityMetadata.cs | 56 +++ ...chedulerOnDemandSandboxWorkerExtensions.cs | 2 +- .../AzureManaged}/OnDemandSandbox/Logs.cs | 0 .../OnDemandSandboxActivityTracker.cs | 0 ...ActivityWorkerRegistrationHostedService.cs | 4 +- .../OnDemandSandboxWorkerMessageBuilder.cs | 8 +- .../OnDemandSandboxWorkerRuntimeOptions.cs | 4 +- .../AzureManaged/Worker.AzureManaged.csproj | 1 + .../Client.AzureManaged.Tests.csproj | 4 - .../OnDemandSandboxActivitiesClientTests.cs | 363 ++++++++++++++++++ .../AzureManagedOnDemandSandbox.Tests.csproj | 16 - .../OnDemandSandboxActivitiesClientTests.cs | 157 -------- .../OnDemandSandboxActivitiesTests.cs | 191 +-------- .../Worker.AzureManaged.Tests.csproj | 1 + 29 files changed, 449 insertions(+), 468 deletions(-) rename src/{Extensions/AzureManagedOnDemandSandbox => Client/AzureManaged/OnDemandSandbox}/ISandboxWorkerProfile.cs (88%) rename src/{Extensions/AzureManagedOnDemandSandbox/Client => Client/AzureManaged/OnDemandSandbox}/OnDemandSandboxActivitiesClient.cs (98%) rename src/{Extensions/AzureManagedOnDemandSandbox/Client => Client/AzureManaged/OnDemandSandbox}/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs (98%) rename src/{Extensions/AzureManagedOnDemandSandbox => Client/AzureManaged/OnDemandSandbox}/OnDemandSandboxActivityDeclarationBuilder.cs (93%) rename src/{Extensions/AzureManagedOnDemandSandbox => Client/AzureManaged/OnDemandSandbox}/OnDemandSandboxActivityDeclarationResolver.cs (98%) rename src/{Extensions/AzureManagedOnDemandSandbox => Client/AzureManaged/OnDemandSandbox}/OnDemandSandboxOptions.cs (94%) rename src/{Extensions/AzureManagedOnDemandSandbox => Client/AzureManaged/OnDemandSandbox}/OnDemandSandboxWorkerProfileAttribute.cs (94%) delete mode 100644 src/Extensions/AzureManagedOnDemandSandbox/AzureManagedOnDemandSandbox.csproj rename src/{Extensions/AzureManagedOnDemandSandbox => Shared/AzureManaged/OnDemandSandbox}/OnDemandSandboxActivitiesGrpcTransport.cs (99%) create mode 100644 src/Shared/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityMetadata.cs rename src/{Extensions/AzureManagedOnDemandSandbox => Worker/AzureManaged/OnDemandSandbox}/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs (99%) rename src/{Extensions/AzureManagedOnDemandSandbox/Worker => Worker/AzureManaged}/OnDemandSandbox/Logs.cs (100%) rename src/{Extensions/AzureManagedOnDemandSandbox/Worker => Worker/AzureManaged}/OnDemandSandbox/OnDemandSandboxActivityTracker.cs (100%) rename src/{Extensions/AzureManagedOnDemandSandbox/Worker => Worker/AzureManaged}/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs (98%) rename src/{Extensions/AzureManagedOnDemandSandbox/Worker => Worker/AzureManaged}/OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs (91%) rename src/{Extensions/AzureManagedOnDemandSandbox/Worker => Worker/AzureManaged}/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs (96%) create mode 100644 test/Client/AzureManaged.Tests/OnDemandSandboxActivitiesClientTests.cs delete mode 100644 test/Extensions/AzureManagedOnDemandSandbox.Tests/AzureManagedOnDemandSandbox.Tests.csproj delete mode 100644 test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientTests.cs rename test/{Extensions/AzureManagedOnDemandSandbox.Tests => Worker/AzureManaged.Tests}/OnDemandSandboxActivitiesTests.cs (80%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cedf21a..84ae5e55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Moved private preview on-demand sandbox APIs into the existing Azure Managed client and worker packages, with shared internal transport code under the Azure Managed shared sources. - Updated private preview on-demand sandbox worker profile declarations to use `OnDemandSandboxOptions.AddActivity(...)`, and updated the on-demand sandbox sample to share activity name constants between the main app and remote worker. - Added SDK-side validation for private preview on-demand sandbox CPU and memory resource quantities. diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index 7cad2b4e..4794ce2a 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -117,24 +117,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReplaySafeLoggerFactorySamp EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkerVersioningSample", "samples\WorkerVersioningSample\WorkerVersioningSample.csproj", "{26988639-D204-4E0B-80BE-F4E11952DFF8}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{D4587EC0-1B16-8420-7502-A967139249D4}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{53193780-CD18-2643-6953-C26F59EAEDF5}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EternalOrchestrationVersionMigrationSample", "samples\EternalOrchestrationVersionMigrationSample\EternalOrchestrationVersionMigrationSample.csproj", "{1E30F09F-1ADA-4375-81CC-F0FBC74D5621}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActivityVersioningSample", "samples\ActivityVersioningSample\ActivityVersioningSample.csproj", "{3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityWithVersionedOrchestrationSample", "samples\EntityWithVersionedOrchestrationSample\EntityWithVersionedOrchestrationSample.csproj", "{8E0D27B3-2B5D-4B6F-A4E6-5C8E7B0F7DD2}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{21303FBF-2A2B-17C2-D2DF-3E924022E940}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureManagedOnDemandSandbox", "src\Extensions\AzureManagedOnDemandSandbox\AzureManagedOnDemandSandbox.csproj", "{5EB60F82-0C88-4495-A65A-D8799E54D28C}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{00205C88-F000-28F2-A910-C6FA00E065EE}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureManagedOnDemandSandbox.Tests", "test\Extensions\AzureManagedOnDemandSandbox.Tests\AzureManagedOnDemandSandbox.Tests.csproj", "{44640E05-EC62-48A8-BD24-9161EF6A40A5}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "on-demand-sandbox", "on-demand-sandbox", "{D422B5FD-8E3C-6588-ACD1-DEFAB429269C}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "shared", "samples\on-demand-sandbox\shared\shared.csproj", "{F0DC6D16-C9BC-4804-BBAF-84C050D5E279}" @@ -777,30 +765,6 @@ Global {8E0D27B3-2B5D-4B6F-A4E6-5C8E7B0F7DD2}.Release|x64.Build.0 = Release|Any CPU {8E0D27B3-2B5D-4B6F-A4E6-5C8E7B0F7DD2}.Release|x86.ActiveCfg = Release|Any CPU {8E0D27B3-2B5D-4B6F-A4E6-5C8E7B0F7DD2}.Release|x86.Build.0 = Release|Any CPU - {5EB60F82-0C88-4495-A65A-D8799E54D28C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5EB60F82-0C88-4495-A65A-D8799E54D28C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5EB60F82-0C88-4495-A65A-D8799E54D28C}.Debug|x64.ActiveCfg = Debug|Any CPU - {5EB60F82-0C88-4495-A65A-D8799E54D28C}.Debug|x64.Build.0 = Debug|Any CPU - {5EB60F82-0C88-4495-A65A-D8799E54D28C}.Debug|x86.ActiveCfg = Debug|Any CPU - {5EB60F82-0C88-4495-A65A-D8799E54D28C}.Debug|x86.Build.0 = Debug|Any CPU - {5EB60F82-0C88-4495-A65A-D8799E54D28C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5EB60F82-0C88-4495-A65A-D8799E54D28C}.Release|Any CPU.Build.0 = Release|Any CPU - {5EB60F82-0C88-4495-A65A-D8799E54D28C}.Release|x64.ActiveCfg = Release|Any CPU - {5EB60F82-0C88-4495-A65A-D8799E54D28C}.Release|x64.Build.0 = Release|Any CPU - {5EB60F82-0C88-4495-A65A-D8799E54D28C}.Release|x86.ActiveCfg = Release|Any CPU - {5EB60F82-0C88-4495-A65A-D8799E54D28C}.Release|x86.Build.0 = Release|Any CPU - {44640E05-EC62-48A8-BD24-9161EF6A40A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {44640E05-EC62-48A8-BD24-9161EF6A40A5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {44640E05-EC62-48A8-BD24-9161EF6A40A5}.Debug|x64.ActiveCfg = Debug|Any CPU - {44640E05-EC62-48A8-BD24-9161EF6A40A5}.Debug|x64.Build.0 = Debug|Any CPU - {44640E05-EC62-48A8-BD24-9161EF6A40A5}.Debug|x86.ActiveCfg = Debug|Any CPU - {44640E05-EC62-48A8-BD24-9161EF6A40A5}.Debug|x86.Build.0 = Debug|Any CPU - {44640E05-EC62-48A8-BD24-9161EF6A40A5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {44640E05-EC62-48A8-BD24-9161EF6A40A5}.Release|Any CPU.Build.0 = Release|Any CPU - {44640E05-EC62-48A8-BD24-9161EF6A40A5}.Release|x64.ActiveCfg = Release|Any CPU - {44640E05-EC62-48A8-BD24-9161EF6A40A5}.Release|x64.Build.0 = Release|Any CPU - {44640E05-EC62-48A8-BD24-9161EF6A40A5}.Release|x86.ActiveCfg = Release|Any CPU - {44640E05-EC62-48A8-BD24-9161EF6A40A5}.Release|x86.Build.0 = Release|Any CPU {F0DC6D16-C9BC-4804-BBAF-84C050D5E279}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F0DC6D16-C9BC-4804-BBAF-84C050D5E279}.Debug|Any CPU.Build.0 = Debug|Any CPU {F0DC6D16-C9BC-4804-BBAF-84C050D5E279}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -895,15 +859,9 @@ Global {5A69FD28-D814-490E-A76B-B0A5F88C25B2} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {26988639-D204-4E0B-80BE-F4E11952DFF8} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} - {D4587EC0-1B16-8420-7502-A967139249D4} = {1C217BB2-CE16-41CC-9D47-0FC0DB60BDB3} - {53193780-CD18-2643-6953-C26F59EAEDF5} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5} {1E30F09F-1ADA-4375-81CC-F0FBC74D5621} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {8E0D27B3-2B5D-4B6F-A4E6-5C8E7B0F7DD2} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} - {21303FBF-2A2B-17C2-D2DF-3E924022E940} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B} - {5EB60F82-0C88-4495-A65A-D8799E54D28C} = {21303FBF-2A2B-17C2-D2DF-3E924022E940} - {00205C88-F000-28F2-A910-C6FA00E065EE} = {E5637F81-2FB9-4CD7-900D-455363B142A7} - {44640E05-EC62-48A8-BD24-9161EF6A40A5} = {00205C88-F000-28F2-A910-C6FA00E065EE} {D422B5FD-8E3C-6588-ACD1-DEFAB429269C} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {F0DC6D16-C9BC-4804-BBAF-84C050D5E279} = {D422B5FD-8E3C-6588-ACD1-DEFAB429269C} {0CB44F0A-3483-4052-A49F-D4E6F140741C} = {D422B5FD-8E3C-6588-ACD1-DEFAB429269C} diff --git a/samples/on-demand-sandbox/main-app/WorkerProfiles.cs b/samples/on-demand-sandbox/main-app/WorkerProfiles.cs index b91596d7..2c83df65 100644 --- a/samples/on-demand-sandbox/main-app/WorkerProfiles.cs +++ b/samples/on-demand-sandbox/main-app/WorkerProfiles.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; +using Microsoft.DurableTask.Client.AzureManaged; using Microsoft.DurableTask.Samples.OnDemandSandbox.Shared; namespace Microsoft.DurableTask.Samples.OnDemandSandbox.MainApp; diff --git a/samples/on-demand-sandbox/main-app/main-app.csproj b/samples/on-demand-sandbox/main-app/main-app.csproj index 70760f5d..d4d7e76d 100644 --- a/samples/on-demand-sandbox/main-app/main-app.csproj +++ b/samples/on-demand-sandbox/main-app/main-app.csproj @@ -16,7 +16,6 @@ - diff --git a/samples/on-demand-sandbox/remote-worker/remote-worker.csproj b/samples/on-demand-sandbox/remote-worker/remote-worker.csproj index 8ad4fad5..e25c2bc9 100644 --- a/samples/on-demand-sandbox/remote-worker/remote-worker.csproj +++ b/samples/on-demand-sandbox/remote-worker/remote-worker.csproj @@ -14,7 +14,6 @@ - diff --git a/src/Client/AzureManaged/Client.AzureManaged.csproj b/src/Client/AzureManaged/Client.AzureManaged.csproj index 7dd459f3..8f3ffa99 100644 --- a/src/Client/AzureManaged/Client.AzureManaged.csproj +++ b/src/Client/AzureManaged/Client.AzureManaged.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Extensions/AzureManagedOnDemandSandbox/ISandboxWorkerProfile.cs b/src/Client/AzureManaged/OnDemandSandbox/ISandboxWorkerProfile.cs similarity index 88% rename from src/Extensions/AzureManagedOnDemandSandbox/ISandboxWorkerProfile.cs rename to src/Client/AzureManaged/OnDemandSandbox/ISandboxWorkerProfile.cs index ac7b6b40..f6b9bc82 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/ISandboxWorkerProfile.cs +++ b/src/Client/AzureManaged/OnDemandSandbox/ISandboxWorkerProfile.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace Microsoft.DurableTask.AzureManaged.OnDemandSandbox; +namespace Microsoft.DurableTask.Client.AzureManaged; /// /// Configures an on-demand sandbox worker profile declaration. diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs b/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivitiesClient.cs similarity index 98% rename from src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs rename to src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivitiesClient.cs index 22cade53..163f4d48 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClient.cs +++ b/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivitiesClient.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; +using Microsoft.DurableTask.AzureManaged.Internal; using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; namespace Microsoft.DurableTask.Client.AzureManaged; diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs b/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs similarity index 98% rename from src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs rename to src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs index 1156ecdc..0704537f 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Client/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs +++ b/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs @@ -2,7 +2,7 @@ // Licensed under the MIT License. using Grpc.Net.Client; -using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; +using Microsoft.DurableTask.AzureManaged.Internal; using Microsoft.DurableTask.Client.Grpc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; diff --git a/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs b/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs similarity index 93% rename from src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs rename to src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs index ae15a75d..1e8ef237 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs +++ b/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs @@ -2,9 +2,10 @@ // Licensed under the MIT License. using System.Globalization; +using Microsoft.DurableTask.AzureManaged.Internal; using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; -namespace Microsoft.DurableTask.AzureManaged.OnDemandSandbox; +namespace Microsoft.DurableTask.Client.AzureManaged; /// /// Builds and normalizes on-demand sandbox activity declaration protocol messages. @@ -18,11 +19,7 @@ static class OnDemandSandboxActivityDeclarationBuilder /// The normalized activity names. public static string[] ResolveActivityNames(IEnumerable configuredNames) { - return configuredNames - .Where(static name => !string.IsNullOrWhiteSpace(name)) - .Select(static name => name.Trim()) - .Distinct(StringComparer.Ordinal) - .ToArray(); + return OnDemandSandboxActivityMetadata.ResolveActivityNames(configuredNames); } /// @@ -74,17 +71,12 @@ public static Proto.OnDemandSandboxActivityDeclaration BuildDeclaration( internal static string NormalizeWorkerProfileId(string value, string errorMessage) { - return NormalizeRequired(value, errorMessage); + return OnDemandSandboxActivityMetadata.NormalizeRequired(value, errorMessage); } internal static string NormalizeRequired(string value, string errorMessage) { - if (string.IsNullOrWhiteSpace(value)) - { - throw new InvalidOperationException(errorMessage); - } - - return value.Trim(); + return OnDemandSandboxActivityMetadata.NormalizeRequired(value, errorMessage); } static Proto.OnDemandSandboxActivityImage BuildImage(OnDemandSandboxOptions options) diff --git a/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs b/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs similarity index 98% rename from src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs rename to src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs index 614b40ae..d245795c 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs +++ b/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs @@ -4,7 +4,7 @@ using System.Reflection; using System.Threading; -namespace Microsoft.DurableTask.AzureManaged.OnDemandSandbox; +namespace Microsoft.DurableTask.Client.AzureManaged; /// /// Resolves on-demand sandbox activity declarations from worker profile configuration. diff --git a/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxOptions.cs b/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxOptions.cs similarity index 94% rename from src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxOptions.cs rename to src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxOptions.cs index be38a51b..e81fa0ee 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxOptions.cs +++ b/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxOptions.cs @@ -2,9 +2,10 @@ // Licensed under the MIT License. using System.Reflection; -using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask; +using Microsoft.DurableTask.AzureManaged.Internal; -namespace Microsoft.DurableTask.AzureManaged.OnDemandSandbox; +namespace Microsoft.DurableTask.Client.AzureManaged; /// /// Options for declaring on-demand sandbox activities and the worker image DTS should start for them. @@ -14,7 +15,7 @@ public sealed class OnDemandSandboxOptions /// /// Default worker profile ID used when no profile is specified. /// - internal const string DefaultWorkerProfileId = "default"; + internal const string DefaultWorkerProfileId = OnDemandSandboxActivityMetadata.DefaultWorkerProfileId; /// /// Gets or sets the task hub where the on-demand sandbox activity declaration is stored. diff --git a/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs b/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs similarity index 94% rename from src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs rename to src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs index c6159479..cdd78759 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs +++ b/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace Microsoft.DurableTask.AzureManaged.OnDemandSandbox; +namespace Microsoft.DurableTask.Client.AzureManaged; /// /// Declares an on-demand sandbox worker profile that DTS can start for activities declared by the profile. diff --git a/src/Extensions/AzureManagedOnDemandSandbox/AzureManagedOnDemandSandbox.csproj b/src/Extensions/AzureManagedOnDemandSandbox/AzureManagedOnDemandSandbox.csproj deleted file mode 100644 index 65bfc65f..00000000 --- a/src/Extensions/AzureManagedOnDemandSandbox/AzureManagedOnDemandSandbox.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - net6.0;net8.0;net10.0 - Azure Managed on-demand sandbox activities support for Durable Task. - Microsoft.DurableTask.AzureManaged.OnDemandSandbox - true - - - - - - - - - - - - - - - - - - diff --git a/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs b/src/Shared/AzureManaged/OnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs similarity index 99% rename from src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs rename to src/Shared/AzureManaged/OnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs index c67998ca..bd96ea7e 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs +++ b/src/Shared/AzureManaged/OnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs @@ -4,7 +4,7 @@ using Grpc.Core; using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; -namespace Microsoft.DurableTask.AzureManaged.OnDemandSandbox; +namespace Microsoft.DurableTask.AzureManaged.Internal; /// /// Transport abstraction for the on-demand sandbox activities gRPC service. diff --git a/src/Shared/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityMetadata.cs b/src/Shared/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityMetadata.cs new file mode 100644 index 00000000..076cf5be --- /dev/null +++ b/src/Shared/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityMetadata.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.AzureManaged.Internal; + +/// +/// Shared normalization helpers for on-demand sandbox activity metadata. +/// +static class OnDemandSandboxActivityMetadata +{ + /// + /// Default worker profile ID used when no profile is specified. + /// + public const string DefaultWorkerProfileId = "default"; + + /// + /// Resolves configured activity names for on-demand sandbox activity execution. + /// + /// The configured activity names. + /// The normalized activity names. + public static string[] ResolveActivityNames(IEnumerable configuredNames) + { + return configuredNames + .Where(static name => !string.IsNullOrWhiteSpace(name)) + .Select(static name => name.Trim()) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + /// + /// Normalizes a worker profile ID. + /// + /// The worker profile ID. + /// The exception message to use when the value is empty. + /// The normalized worker profile ID. + public static string NormalizeWorkerProfileId(string value, string errorMessage) + { + return NormalizeRequired(value, errorMessage); + } + + /// + /// Normalizes a required string. + /// + /// The value to normalize. + /// The exception message to use when the value is empty. + /// The normalized value. + public static string NormalizeRequired(string value, string errorMessage) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException(errorMessage); + } + + return value.Trim(); + } +} diff --git a/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs b/src/Worker/AzureManaged/OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs similarity index 99% rename from src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs rename to src/Worker/AzureManaged/OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs index 5118cf66..5e8850c5 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs +++ b/src/Worker/AzureManaged/OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs @@ -6,7 +6,7 @@ using Azure.Core; using Azure.Identity; using Grpc.Net.Client; -using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; +using Microsoft.DurableTask.AzureManaged.Internal; using Microsoft.DurableTask.Protobuf.OnDemandSandbox; using Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; using Microsoft.DurableTask.Worker.Grpc; diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/Logs.cs b/src/Worker/AzureManaged/OnDemandSandbox/Logs.cs similarity index 100% rename from src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/Logs.cs rename to src/Worker/AzureManaged/OnDemandSandbox/Logs.cs diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityTracker.cs b/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityTracker.cs similarity index 100% rename from src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityTracker.cs rename to src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityTracker.cs diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs b/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs similarity index 98% rename from src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs rename to src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs index 00a1fe4d..2cd5cac6 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs +++ b/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs @@ -3,7 +3,7 @@ using System.IO; using Grpc.Core; -using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; +using Microsoft.DurableTask.AzureManaged.Internal; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; @@ -65,7 +65,7 @@ public Task StartAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - string[] activityNames = OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(this.registeredActivityNames); + string[] activityNames = OnDemandSandboxActivityMetadata.ResolveActivityNames(this.registeredActivityNames); if (activityNames.Length == 0) { Logs.NoOnDemandSandboxActivitiesForWorkerRegistration(this.logger, this.options.TaskHub); diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs b/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs similarity index 91% rename from src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs rename to src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs index ac360510..0a819004 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs +++ b/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; +using Microsoft.DurableTask.AzureManaged.Internal; using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; @@ -24,10 +24,10 @@ public static Proto.OnDemandSandboxActivityWorkerMessage BuildWorkerStart( Check.NotNull(options); Check.NotNull(registeredActivityNames); - _ = OnDemandSandboxActivityDeclarationBuilder.NormalizeRequired( + _ = OnDemandSandboxActivityMetadata.NormalizeRequired( options.TaskHub, "On-demand sandbox activity worker registration requires a task hub name."); - string[] activityNames = OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(registeredActivityNames); + string[] activityNames = OnDemandSandboxActivityMetadata.ResolveActivityNames(registeredActivityNames); if (activityNames.Length == 0) { throw new InvalidOperationException("On-demand sandbox activity worker registration requires at least one registered activity."); @@ -38,7 +38,7 @@ public static Proto.OnDemandSandboxActivityWorkerMessage BuildWorkerStart( throw new InvalidOperationException("On-demand sandbox activity worker max activity count must be greater than zero."); } - string workerProfileId = OnDemandSandboxActivityDeclarationBuilder.NormalizeWorkerProfileId( + string workerProfileId = OnDemandSandboxActivityMetadata.NormalizeWorkerProfileId( options.WorkerProfileId, "On-demand sandbox activity worker registration requires a worker profile ID."); diff --git a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs b/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs similarity index 96% rename from src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs rename to src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs index 0a557f99..38ce7ab0 100644 --- a/src/Extensions/AzureManagedOnDemandSandbox/Worker/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs +++ b/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; +using Microsoft.DurableTask.AzureManaged.Internal; namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; @@ -34,7 +34,7 @@ internal sealed class OnDemandSandboxWorkerRuntimeOptions /// /// Gets or sets the worker profile ID used by on-demand sandbox worker registration. /// - public string WorkerProfileId { get; set; } = OnDemandSandboxOptions.DefaultWorkerProfileId; + public string WorkerProfileId { get; set; } = OnDemandSandboxActivityMetadata.DefaultWorkerProfileId; /// /// Gets or sets the maximum number of concurrent activities expected from this on-demand sandbox worker. diff --git a/src/Worker/AzureManaged/Worker.AzureManaged.csproj b/src/Worker/AzureManaged/Worker.AzureManaged.csproj index e433b192..49da9eef 100644 --- a/src/Worker/AzureManaged/Worker.AzureManaged.csproj +++ b/src/Worker/AzureManaged/Worker.AzureManaged.csproj @@ -12,6 +12,7 @@ + diff --git a/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj b/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj index ba569a77..d890a619 100644 --- a/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj +++ b/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj @@ -4,10 +4,6 @@ net10.0 - - - - diff --git a/test/Client/AzureManaged.Tests/OnDemandSandboxActivitiesClientTests.cs b/test/Client/AzureManaged.Tests/OnDemandSandboxActivitiesClientTests.cs new file mode 100644 index 00000000..39eb47af --- /dev/null +++ b/test/Client/AzureManaged.Tests/OnDemandSandboxActivitiesClientTests.cs @@ -0,0 +1,363 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using FluentAssertions; +using Grpc.Core; +using Microsoft.DurableTask.AzureManaged.Internal; +using Microsoft.DurableTask.Client.Grpc; +using Microsoft.DurableTask.Protobuf.OnDemandSandbox; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.DurableTask.Client.AzureManaged.Tests; + +public class OnDemandSandboxActivitiesClientTests +{ + const string TaskHub = "testhub"; + + [Fact] + public void OnDemandSandboxDeclarationContract_DoesNotExposeRemovedOptions() + { + typeof(OnDemandSandboxOptions).GetProperty("LaunchCommand").Should().BeNull(); + typeof(OnDemandSandboxOptions).GetProperty("DeclarationRetryMaxAttempts").Should().BeNull(); + typeof(OnDemandSandboxOptions).GetProperty("DeclarationRetryDelay").Should().BeNull(); + typeof(OnDemandSandboxOptions).GetProperty( + "HeartbeatInterval", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); + typeof(OnDemandSandboxOptions).GetProperty("WakeupPort").Should().BeNull(); + typeof(OnDemandSandboxOptions).GetProperty( + "WorkerRegistrationRetryInitialDelay", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); + typeof(OnDemandSandboxOptions).GetProperty( + "WorkerRegistrationRetryMaxDelay", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); + typeof(OnDemandSandboxOptions).GetProperty( + "Mode", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); + typeof(OnDemandSandboxActivityDeclaration).GetProperty("LaunchCommand").Should().BeNull(); + } + + [Fact] + public void OnDemandSandboxDeclarationContract_ExposesProfileAddActivityOnly() + { + // Arrange + Type optionsType = typeof(OnDemandSandboxOptions); + Type? activityAttributeType = typeof(OnDemandSandboxOptions).Assembly.GetType( + "Microsoft.DurableTask.Client.AzureManaged.OnDemandSandboxActivityAttribute"); + + // Act/Assert + optionsType.GetProperty("ActivityNames").Should().BeNull(); + optionsType.GetMethod("AddActivity", [typeof(string)]).Should().NotBeNull(); + optionsType.GetMethods().Should().Contain(method => + method.Name == "AddActivity" && method.IsGenericMethodDefinition); + activityAttributeType.Should().BeNull(); + } + + [Theory] + [InlineData("500m", "1024Mi")] + [InlineData("0.5", "1Gi")] + [InlineData("2", "2048")] + public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_AcceptsAdcResourceQuantities( + string cpu, + string memory) + { + // Arrange + OnDemandSandboxOptions options = CreateDeclarationOptions(); + options.Cpu = cpu; + options.Memory = memory; + + // Act + OnDemandSandboxActivityDeclaration declaration = OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( + options, + OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); + + // Assert + declaration.Resources.Cpu.Should().Be(cpu); + declaration.Resources.Memory.Should().Be(memory); + } + + [Theory] + [InlineData("0", "1024Mi", "CPU")] + [InlineData("0m", "1024Mi", "CPU")] + [InlineData("500Mi", "1024Mi", "CPU")] + [InlineData("500m", "0", "memory")] + [InlineData("500m", "0Mi", "memory")] + [InlineData("500m", "500m", "memory")] + public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_RejectsInvalidAdcResourceQuantities( + string cpu, + string memory, + string expectedMessage) + { + // Arrange + OnDemandSandboxOptions options = CreateDeclarationOptions(); + options.Cpu = cpu; + options.Memory = memory; + + // Act + Action action = () => OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( + options, + OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); + + // Assert + action.Should().Throw() + .WithMessage($"*{expectedMessage}*"); + } + + [Fact] + public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_RequiresSchedulerManagedIdentityClientId() + { + // Arrange + OnDemandSandboxOptions options = CreateDeclarationOptions(); + options.SchedulerManagedIdentityClientId = " "; + + // Act + Action action = () => OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( + options, + OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); + + // Assert + action.Should().Throw() + .WithMessage("*managed identity client ID*"); + } + + [Fact] + public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_RequiresImagePullManagedIdentityClientId() + { + // Arrange + OnDemandSandboxOptions options = CreateDeclarationOptions(); + options.ImagePullManagedIdentityClientId = " "; + + // Act + Action action = () => OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( + options, + OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); + + // Assert + action.Should().Throw() + .WithMessage("*managed identity client ID ADC uses to pull the worker image*"); + } + + [Fact] + public void OnDemandSandboxActivityDeclarationResolver_ResolveDeclarations_UsesWorkerProfileConfigure() + { + // Arrange + using EnvironmentVariableScope image = new("DTS_ON_DEMAND_SANDBOX_ACTIVITY_IMAGE", "example.com/not-used:latest"); + using EnvironmentVariableScope cpu = new("DTS_ON_DEMAND_SANDBOX_CPU", "2000m"); + using EnvironmentVariableScope memory = new("DTS_ON_DEMAND_SANDBOX_MEMORY", "4096Mi"); + using EnvironmentVariableScope maxActivities = new("DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES", "99"); + + // Act + OnDemandSandboxOptions options = OnDemandSandboxActivityDeclarationResolver.ResolveDeclarations(TaskHub) + .Single(options => options.WorkerProfileId == "annotated-profile"); + OnDemandSandboxActivityDeclaration declaration = OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( + options, + OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); + + // Assert + declaration.WorkerProfileId.Should().Be("annotated-profile"); + declaration.ActivityNames.Should().Equal("ConfiguredRemoteHello"); + declaration.Image.ImageRef.Should().Be("example.com/repo/annotated-worker:latest"); + declaration.Image.ManagedIdentityClientId.Should().Be("image-pull-client-id"); + declaration.SchedulerManagedIdentityClientId.Should().Be("scheduler-client-id"); + declaration.Resources.Cpu.Should().Be("500m"); + declaration.Resources.Memory.Should().Be("1024Mi"); + declaration.MaxConcurrentActivities.Should().Be(4); + declaration.EnvironmentVariables.Should().ContainKey("CUSTOM_ENV").WhoseValue.Should().Be("configured-value"); + declaration.Entrypoint.Should().BeEmpty(); + declaration.Cmd.Should().BeEmpty(); + } + + [Fact] + public async Task AddDurableTaskSchedulerOnDemandSandboxActivitiesClient_UsesConfiguredDurableTaskClientInvoker() + { + // Arrange + RecordingOnDemandSandboxLogCallInvoker callInvoker = new(); + ServiceCollection services = new(); + services.AddOptions(Options.DefaultName) + .Configure(options => options.TaskHubName = "client-test-taskhub"); + services.AddOptions(Options.DefaultName) + .Configure(options => options.CallInvoker = callInvoker); + services.AddDurableTaskSchedulerOnDemandSandboxActivitiesClient(); + + using ServiceProvider provider = services.BuildServiceProvider(); + OnDemandSandboxActivitiesClient client = provider.GetRequiredService(); + + // Act + await client.RemoveOnDemandSandboxActivityDeclarationAsync("default"); + + // Assert + callInvoker.RemoveRequest.Should().NotBeNull(); + callInvoker.RemoveRequest!.WorkerProfileId.Should().Be("default"); + } + + [Fact] + public async Task EnableOnDemandSandboxActivitiesAsync_SendsWorkerProfileDeclarations() + { + // Arrange + RecordingOnDemandSandboxLogCallInvoker callInvoker = new(); + OnDemandSandboxActivitiesClient client = new( + new OnDemandSandboxActivitiesGrpcTransport(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)), + "client-test-taskhub"); + + // Act + await client.EnableOnDemandSandboxActivitiesAsync(); + + // Assert + OnDemandSandboxActivityDeclaration declaration = callInvoker.DeclareRequests + .Should() + .ContainSingle(request => request.WorkerProfileId == "client-test-profile") + .Subject; + declaration.ActivityNames.Should().Equal("ClientTestRemoteActivity"); + declaration.Image.ImageRef.Should().Be("example.com/client-test-worker:latest"); + callInvoker.DeclareHeaders.Should().Contain(header => header.Key == "taskhub" && header.Value == "client-test-taskhub"); + callInvoker.UnaryDisposeCount.Should().BeGreaterThan(0); + } + + sealed class RecordingOnDemandSandboxLogCallInvoker : CallInvoker + { + public List DeclareRequests { get; } = []; + + public Metadata DeclareHeaders { get; private set; } = []; + + public RemoveOnDemandSandboxActivityDeclarationRequest? RemoveRequest { get; private set; } + + public Metadata RemoveHeaders { get; private set; } = []; + + public int UnaryDisposeCount { get; private set; } + + public override TResponse BlockingUnaryCall( + Method method, + string? host, + CallOptions options, + TRequest request) + { + throw new NotSupportedException(); + } + + public override AsyncUnaryCall AsyncUnaryCall( + Method method, + string? host, + CallOptions options, + TRequest request) + { + if (method.FullName.EndsWith("/DeclareOnDemandSandboxActivities", StringComparison.Ordinal)) + { + this.DeclareRequests.Add(((OnDemandSandboxActivityDeclaration)(object)request).Clone()); + this.DeclareHeaders = options.Headers ?? []; + return CreateUnaryCall((TResponse)(object)new OnDemandSandboxActivityDeclarationResult()); + } + + if (method.FullName.EndsWith("/RemoveOnDemandSandboxActivityDeclaration", StringComparison.Ordinal)) + { + this.RemoveRequest = (RemoveOnDemandSandboxActivityDeclarationRequest)(object)request; + this.RemoveHeaders = options.Headers ?? []; + return CreateUnaryCall((TResponse)(object)new RemoveOnDemandSandboxActivityDeclarationResult()); + } + + throw new NotSupportedException(method.FullName); + } + + public override AsyncServerStreamingCall AsyncServerStreamingCall( + Method method, + string? host, + CallOptions options, + TRequest request) + { + throw new NotSupportedException(); + } + + public override AsyncClientStreamingCall AsyncClientStreamingCall( + Method method, + string? host, + CallOptions options) + { + throw new NotSupportedException(); + } + + public override AsyncDuplexStreamingCall AsyncDuplexStreamingCall( + Method method, + string? host, + CallOptions options) + { + throw new NotSupportedException(); + } + + AsyncUnaryCall CreateUnaryCall(TResponse response) + { + return new AsyncUnaryCall( + Task.FromResult(response), + Task.FromResult(new Metadata()), + () => new Status(StatusCode.OK, string.Empty), + () => new Metadata(), + () => this.UnaryDisposeCount++); + } + } + + static OnDemandSandboxOptions CreateDeclarationOptions() + { + OnDemandSandboxOptions options = new() + { + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", + ImagePullManagedIdentityClientId = "image-pull-client-id", + SchedulerManagedIdentityClientId = "scheduler-client-id", + Cpu = "500m", + Memory = "1024Mi", + MaxConcurrentActivities = 7, + }; + options.AddActivity("RemoteHello"); + return options; + } + + [OnDemandSandboxWorkerProfile("client-test-profile")] + sealed class ClientTestWorkerProfile : ISandboxWorkerProfile + { + public void Configure(OnDemandSandboxOptions options) + { + options.ContainerImage = "example.com/client-test-worker:latest"; + options.ImagePullManagedIdentityClientId = "image-pull-client-id"; + options.SchedulerManagedIdentityClientId = "scheduler-client-id"; + options.Cpu = "500m"; + options.Memory = "1024Mi"; + options.MaxConcurrentActivities = 4; + options.AddActivity("ClientTestRemoteActivity"); + } + } + + [OnDemandSandboxWorkerProfile("annotated-profile")] + sealed class AnnotatedWorkerProfile : ISandboxWorkerProfile + { + public static int ConfigureCallCount { get; private set; } + + public void Configure(OnDemandSandboxOptions options) + { + ConfigureCallCount++; + options.ContainerImage = "example.com/repo/annotated-worker:latest"; + options.ImagePullManagedIdentityClientId = "image-pull-client-id"; + options.SchedulerManagedIdentityClientId = "scheduler-client-id"; + options.Cpu = "500m"; + options.Memory = "1024Mi"; + options.MaxConcurrentActivities = 4; + options.EnvironmentVariables["CUSTOM_ENV"] = "configured-value"; + options.AddActivity("ConfiguredRemoteHello"); + } + } + + sealed class EnvironmentVariableScope : IDisposable + { + readonly string name; + readonly string? originalValue; + + public EnvironmentVariableScope(string name, string? value) + { + this.name = name; + this.originalValue = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() => Environment.SetEnvironmentVariable(this.name, this.originalValue); + } +} diff --git a/test/Extensions/AzureManagedOnDemandSandbox.Tests/AzureManagedOnDemandSandbox.Tests.csproj b/test/Extensions/AzureManagedOnDemandSandbox.Tests/AzureManagedOnDemandSandbox.Tests.csproj deleted file mode 100644 index a1bb8b8f..00000000 --- a/test/Extensions/AzureManagedOnDemandSandbox.Tests/AzureManagedOnDemandSandbox.Tests.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - net10.0 - - - - - - - - - - - - diff --git a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientTests.cs b/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientTests.cs deleted file mode 100644 index 63f1b3d8..00000000 --- a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesClientTests.cs +++ /dev/null @@ -1,157 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using FluentAssertions; -using Grpc.Core; -using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; -using Microsoft.DurableTask.Client.Grpc; -using Microsoft.DurableTask.Protobuf.OnDemandSandbox; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Xunit; - -namespace Microsoft.DurableTask.Client.AzureManaged.Tests; - -public class OnDemandSandboxActivitiesClientTests -{ - [Fact] - public async Task AddDurableTaskSchedulerOnDemandSandboxActivitiesClient_UsesConfiguredDurableTaskClientInvoker() - { - // Arrange - RecordingOnDemandSandboxLogCallInvoker callInvoker = new(); - ServiceCollection services = new(); - services.AddOptions(Options.DefaultName) - .Configure(options => options.TaskHubName = "client-test-taskhub"); - services.AddOptions(Options.DefaultName) - .Configure(options => options.CallInvoker = callInvoker); - services.AddDurableTaskSchedulerOnDemandSandboxActivitiesClient(); - - using ServiceProvider provider = services.BuildServiceProvider(); - OnDemandSandboxActivitiesClient client = provider.GetRequiredService(); - - // Act - await client.RemoveOnDemandSandboxActivityDeclarationAsync("default"); - - // Assert - callInvoker.RemoveRequest.Should().NotBeNull(); - callInvoker.RemoveRequest!.WorkerProfileId.Should().Be("default"); - } - - [Fact] - public async Task EnableOnDemandSandboxActivitiesAsync_SendsWorkerProfileDeclarations() - { - // Arrange - RecordingOnDemandSandboxLogCallInvoker callInvoker = new(); - OnDemandSandboxActivitiesClient client = new( - new OnDemandSandboxActivitiesGrpcTransport(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)), - "client-test-taskhub"); - - // Act - await client.EnableOnDemandSandboxActivitiesAsync(); - - // Assert - OnDemandSandboxActivityDeclaration declaration = callInvoker.DeclareRequests - .Should() - .ContainSingle(request => request.WorkerProfileId == "client-test-profile") - .Subject; - declaration.ActivityNames.Should().Equal("ClientTestRemoteActivity"); - declaration.Image.ImageRef.Should().Be("example.com/client-test-worker:latest"); - callInvoker.DeclareHeaders.Should().Contain(header => header.Key == "taskhub" && header.Value == "client-test-taskhub"); - callInvoker.UnaryDisposeCount.Should().BeGreaterThan(0); - } - - sealed class RecordingOnDemandSandboxLogCallInvoker : CallInvoker - { - public List DeclareRequests { get; } = []; - - public Metadata DeclareHeaders { get; private set; } = []; - - public RemoveOnDemandSandboxActivityDeclarationRequest? RemoveRequest { get; private set; } - - public Metadata RemoveHeaders { get; private set; } = []; - - public int UnaryDisposeCount { get; private set; } - - public override TResponse BlockingUnaryCall( - Method method, - string? host, - CallOptions options, - TRequest request) - { - throw new NotSupportedException(); - } - - public override AsyncUnaryCall AsyncUnaryCall( - Method method, - string? host, - CallOptions options, - TRequest request) - { - if (method.FullName.EndsWith("/DeclareOnDemandSandboxActivities", StringComparison.Ordinal)) - { - this.DeclareRequests.Add(((OnDemandSandboxActivityDeclaration)(object)request).Clone()); - this.DeclareHeaders = options.Headers ?? []; - return CreateUnaryCall((TResponse)(object)new OnDemandSandboxActivityDeclarationResult()); - } - - if (method.FullName.EndsWith("/RemoveOnDemandSandboxActivityDeclaration", StringComparison.Ordinal)) - { - this.RemoveRequest = (RemoveOnDemandSandboxActivityDeclarationRequest)(object)request; - this.RemoveHeaders = options.Headers ?? []; - return CreateUnaryCall((TResponse)(object)new RemoveOnDemandSandboxActivityDeclarationResult()); - } - - throw new NotSupportedException(method.FullName); - } - - public override AsyncServerStreamingCall AsyncServerStreamingCall( - Method method, - string? host, - CallOptions options, - TRequest request) - { - throw new NotSupportedException(); - } - - public override AsyncClientStreamingCall AsyncClientStreamingCall( - Method method, - string? host, - CallOptions options) - { - throw new NotSupportedException(); - } - - public override AsyncDuplexStreamingCall AsyncDuplexStreamingCall( - Method method, - string? host, - CallOptions options) - { - throw new NotSupportedException(); - } - - AsyncUnaryCall CreateUnaryCall(TResponse response) - { - return new AsyncUnaryCall( - Task.FromResult(response), - Task.FromResult(new Metadata()), - () => new Status(StatusCode.OK, string.Empty), - () => new Metadata(), - () => this.UnaryDisposeCount++); - } - } - - [OnDemandSandboxWorkerProfile("client-test-profile")] - sealed class ClientTestWorkerProfile : ISandboxWorkerProfile - { - public void Configure(OnDemandSandboxOptions options) - { - options.ContainerImage = "example.com/client-test-worker:latest"; - options.ImagePullManagedIdentityClientId = "image-pull-client-id"; - options.SchedulerManagedIdentityClientId = "scheduler-client-id"; - options.Cpu = "500m"; - options.Memory = "1024Mi"; - options.MaxConcurrentActivities = 4; - options.AddActivity("ClientTestRemoteActivity"); - } - } -} diff --git a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs b/test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs similarity index 80% rename from test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs rename to test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs index 32c161d0..5e1ae497 100644 --- a/test/Extensions/AzureManagedOnDemandSandbox.Tests/OnDemandSandboxActivitiesTests.cs +++ b/test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs @@ -1,11 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Reflection; using Azure.Identity; using FluentAssertions; using Grpc.Core; -using Microsoft.DurableTask.AzureManaged.OnDemandSandbox; +using Microsoft.DurableTask.AzureManaged.Internal; using Microsoft.DurableTask.Protobuf.OnDemandSandbox; using Microsoft.DurableTask.Worker.AzureManaged; using Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; @@ -22,128 +21,6 @@ public class OnDemandSandboxActivitiesTests { const string TaskHub = "testhub"; - [Fact] - public void OnDemandSandboxDeclarationContract_DoesNotExposeRemovedOptions() - { - typeof(OnDemandSandboxOptions).GetProperty("LaunchCommand").Should().BeNull(); - typeof(OnDemandSandboxOptions).GetProperty("DeclarationRetryMaxAttempts").Should().BeNull(); - typeof(OnDemandSandboxOptions).GetProperty("DeclarationRetryDelay").Should().BeNull(); - typeof(OnDemandSandboxOptions).GetProperty( - "HeartbeatInterval", - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); - typeof(OnDemandSandboxOptions).GetProperty("WakeupPort").Should().BeNull(); - typeof(OnDemandSandboxOptions).GetProperty( - "WorkerRegistrationRetryInitialDelay", - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); - typeof(OnDemandSandboxOptions).GetProperty( - "WorkerRegistrationRetryMaxDelay", - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); - typeof(OnDemandSandboxOptions).GetProperty( - "Mode", - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); - typeof(OnDemandSandboxActivityDeclaration).GetProperty("LaunchCommand").Should().BeNull(); - } - - [Fact] - public void OnDemandSandboxDeclarationContract_ExposesProfileAddActivityOnly() - { - // Arrange - Type optionsType = typeof(OnDemandSandboxOptions); - Type? activityAttributeType = typeof(OnDemandSandboxOptions).Assembly.GetType( - "Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox.OnDemandSandboxActivityAttribute"); - - // Act/Assert - optionsType.GetProperty("ActivityNames").Should().BeNull(); - optionsType.GetMethod("AddActivity", [typeof(string)]).Should().NotBeNull(); - optionsType.GetMethods().Should().Contain(method => - method.Name == "AddActivity" && method.IsGenericMethodDefinition); - activityAttributeType.Should().BeNull(); - } - - [Theory] - [InlineData("500m", "1024Mi")] - [InlineData("0.5", "1Gi")] - [InlineData("2", "2048")] - public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_AcceptsAdcResourceQuantities( - string cpu, - string memory) - { - // Arrange - OnDemandSandboxOptions options = CreateDeclarationOptions(); - options.Cpu = cpu; - options.Memory = memory; - - // Act - OnDemandSandboxActivityDeclaration declaration = OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( - options, - OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); - - // Assert - declaration.Resources.Cpu.Should().Be(cpu); - declaration.Resources.Memory.Should().Be(memory); - } - - [Theory] - [InlineData("0", "1024Mi", "CPU")] - [InlineData("0m", "1024Mi", "CPU")] - [InlineData("500Mi", "1024Mi", "CPU")] - [InlineData("500m", "0", "memory")] - [InlineData("500m", "0Mi", "memory")] - [InlineData("500m", "500m", "memory")] - public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_RejectsInvalidAdcResourceQuantities( - string cpu, - string memory, - string expectedMessage) - { - // Arrange - OnDemandSandboxOptions options = CreateDeclarationOptions(); - options.Cpu = cpu; - options.Memory = memory; - - // Act - Action action = () => OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( - options, - OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); - - // Assert - action.Should().Throw() - .WithMessage($"*{expectedMessage}*"); - } - - [Fact] - public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_RequiresSchedulerManagedIdentityClientId() - { - // Arrange - OnDemandSandboxOptions options = CreateDeclarationOptions(); - options.SchedulerManagedIdentityClientId = " "; - - // Act - Action action = () => OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( - options, - OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); - - // Assert - action.Should().Throw() - .WithMessage("*managed identity client ID*"); - } - - [Fact] - public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_RequiresImagePullManagedIdentityClientId() - { - // Arrange - OnDemandSandboxOptions options = CreateDeclarationOptions(); - options.ImagePullManagedIdentityClientId = " "; - - // Act - Action action = () => OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( - options, - OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); - - // Assert - action.Should().Throw() - .WithMessage("*managed identity client ID ADC uses to pull the worker image*"); - } - [Fact] public async Task OnDemandSandboxActivitiesGrpcTransport_SendsTaskHubMetadata() { @@ -510,36 +387,6 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_StopAsy session.CompleteCalledWhileWriteActive.Should().BeFalse(); } - [Fact] - public void OnDemandSandboxActivityDeclarationResolver_ResolveDeclarations_UsesWorkerProfileConfigure() - { - // Arrange - using EnvironmentVariableScope image = new("DTS_ON_DEMAND_SANDBOX_ACTIVITY_IMAGE", "example.com/not-used:latest"); - using EnvironmentVariableScope cpu = new("DTS_ON_DEMAND_SANDBOX_CPU", "2000m"); - using EnvironmentVariableScope memory = new("DTS_ON_DEMAND_SANDBOX_MEMORY", "4096Mi"); - using EnvironmentVariableScope maxActivities = new("DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES", "99"); - - // Act - OnDemandSandboxOptions options = OnDemandSandboxActivityDeclarationResolver.ResolveDeclarations(TaskHub) - .Single(options => options.WorkerProfileId == "annotated-profile"); - OnDemandSandboxActivityDeclaration declaration = OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( - options, - OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); - - // Assert - declaration.WorkerProfileId.Should().Be("annotated-profile"); - declaration.ActivityNames.Should().Equal("ConfiguredRemoteHello"); - declaration.Image.ImageRef.Should().Be("example.com/repo/annotated-worker:latest"); - declaration.Image.ManagedIdentityClientId.Should().Be("image-pull-client-id"); - declaration.SchedulerManagedIdentityClientId.Should().Be("scheduler-client-id"); - declaration.Resources.Cpu.Should().Be("500m"); - declaration.Resources.Memory.Should().Be("1024Mi"); - declaration.MaxConcurrentActivities.Should().Be(4); - declaration.EnvironmentVariables.Should().ContainKey("CUSTOM_ENV").WhoseValue.Should().Be("configured-value"); - declaration.Entrypoint.Should().BeEmpty(); - declaration.Cmd.Should().BeEmpty(); - } - [Fact] public async Task UseSandboxWorker_ConfiguresRegisteredActivityWorkerFilter() { @@ -731,42 +578,6 @@ public void UseSandboxWorker_MissingInjectedTaskHub_Throws() .WithMessage("DTS_TASK_HUB must be injected by DTS for on-demand sandbox workers."); } - static OnDemandSandboxOptions CreateDeclarationOptions() - { - OnDemandSandboxOptions options = new() - { - TaskHub = TaskHub, - WorkerProfileId = "profile-a", - ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", - ImagePullManagedIdentityClientId = "image-pull-client-id", - SchedulerManagedIdentityClientId = "scheduler-client-id", - Cpu = "500m", - Memory = "1024Mi", - MaxConcurrentActivities = 7, - }; - options.AddActivity("RemoteHello"); - return options; - } - - [OnDemandSandboxWorkerProfile("annotated-profile")] - sealed class AnnotatedWorkerProfile : ISandboxWorkerProfile - { - public static int ConfigureCallCount { get; private set; } - - public void Configure(OnDemandSandboxOptions options) - { - ConfigureCallCount++; - options.ContainerImage = "example.com/repo/annotated-worker:latest"; - options.ImagePullManagedIdentityClientId = "image-pull-client-id"; - options.SchedulerManagedIdentityClientId = "scheduler-client-id"; - options.Cpu = "500m"; - options.Memory = "1024Mi"; - options.MaxConcurrentActivities = 4; - options.EnvironmentVariables["CUSTOM_ENV"] = "configured-value"; - options.AddActivity("ConfiguredRemoteHello"); - } - } - sealed class FakeOnDemandSandboxActivitiesTransport : IOnDemandSandboxActivitiesTransport { readonly Queue queuedSessions = new(); diff --git a/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj b/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj index 9aab6f15..53fd1b1f 100644 --- a/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj +++ b/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj @@ -5,6 +5,7 @@ + From da25ab997f80e92c0fefb10287fec5cd05a98c04 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 10 Jun 2026 16:53:28 -0700 Subject: [PATCH 56/81] Remove on-demand sandbox worker mode flag Replace the internal mode enum and registration gate with direct DTS_SUBSTRATE validation so UseSandboxWorker always registers when the DTS sandbox environment is valid. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...chedulerOnDemandSandboxWorkerExtensions.cs | 19 +++--- ...ActivityWorkerRegistrationHostedService.cs | 6 -- .../OnDemandSandboxWorkerRuntimeOptions.cs | 21 ------ .../OnDemandSandboxActivitiesTests.cs | 64 +++++++++++++++++-- 4 files changed, 68 insertions(+), 42 deletions(-) diff --git a/src/Worker/AzureManaged/OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs b/src/Worker/AzureManaged/OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs index 5e8850c5..79a2a488 100644 --- a/src/Worker/AzureManaged/OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs +++ b/src/Worker/AzureManaged/OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs @@ -173,12 +173,7 @@ static string GetRequiredEnvironmentVariable(string name) static void ApplyWorkerEnvironmentOverrides(OnDemandSandboxWorkerRuntimeOptions options) { - // Auto-detect worker mode from DTS_SUBSTRATE, which the backend injects when - // launching a sandbox. This is the authoritative signal that this process is a sandbox worker. - if (IsOnDemandSandboxWorkerSubstrate(Environment.GetEnvironmentVariable("DTS_SUBSTRATE"))) - { - options.Mode = OnDemandSandboxMode.OnDemandSandboxInclude; - } + ValidateOnDemandSandboxWorkerSubstrate(GetRequiredEnvironmentVariable("DTS_SUBSTRATE")); ApplyWorkerProfileEnvironmentOverride(profile => options.WorkerProfileId = profile); @@ -197,9 +192,15 @@ static void ApplyWorkerProfileEnvironmentOverride(Action setWorkerProfil } } - static bool IsOnDemandSandboxWorkerSubstrate(string? substrate) - => string.Equals(substrate, "Sandbox", StringComparison.OrdinalIgnoreCase) - || string.Equals(substrate, "AcaSessionPool", StringComparison.OrdinalIgnoreCase); + static void ValidateOnDemandSandboxWorkerSubstrate(string substrate) + { + if (!string.Equals(substrate, "Sandbox", StringComparison.OrdinalIgnoreCase) + && !string.Equals(substrate, "AcaSessionPool", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "DTS_SUBSTRATE must be 'Sandbox' or 'AcaSessionPool' for on-demand sandbox workers."); + } + } static string[] ResolveActivityFilterNames(IReadOnlyList activityFilters) { diff --git a/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs b/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs index 2cd5cac6..bbc624fe 100644 --- a/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs +++ b/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs @@ -59,12 +59,6 @@ public OnDemandSandboxActivityWorkerRegistrationHostedService( /// public Task StartAsync(CancellationToken cancellationToken) { - if (this.options.Mode != OnDemandSandboxMode.OnDemandSandboxInclude) - { - this.pump = Task.CompletedTask; - return Task.CompletedTask; - } - string[] activityNames = OnDemandSandboxActivityMetadata.ResolveActivityNames(this.registeredActivityNames); if (activityNames.Length == 0) { diff --git a/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs b/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs index 38ce7ab0..e78ad29b 100644 --- a/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs +++ b/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs @@ -5,22 +5,6 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; -/// -/// Defines how a worker participates in on-demand sandbox activity execution. -/// -internal enum OnDemandSandboxMode -{ - /// - /// The worker is not running inside on-demand sandbox infrastructure. - /// - LocalExclude, - - /// - /// The worker runs inside on-demand sandbox infrastructure and executes only on-demand sandbox activities. - /// - OnDemandSandboxInclude, -} - /// /// Internal runtime settings for an on-demand sandbox worker process. /// @@ -55,9 +39,4 @@ internal sealed class OnDemandSandboxWorkerRuntimeOptions /// Gets or sets the maximum delay before retrying a failed worker registration stream. /// public TimeSpan WorkerRegistrationRetryMaxDelay { get; set; } = TimeSpan.FromSeconds(30); - - /// - /// Gets or sets the worker mode for on-demand sandbox activity execution. Set automatically from the runtime environment. - /// - public OnDemandSandboxMode Mode { get; set; } = OnDemandSandboxMode.LocalExclude; } diff --git a/test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs b/test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs index 5e1ae497..536c1e25 100644 --- a/test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs +++ b/test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs @@ -102,7 +102,6 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_SendsLi { OnDemandSandboxWorkerRuntimeOptions options = new() { - Mode = OnDemandSandboxMode.OnDemandSandboxInclude, TaskHub = TaskHub, WorkerProfileId = "profile-a", MaxConcurrentActivities = 3, @@ -171,7 +170,6 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_SendsHe // Arrange OnDemandSandboxWorkerRuntimeOptions options = new() { - Mode = OnDemandSandboxMode.OnDemandSandboxInclude, TaskHub = TaskHub, WorkerProfileId = "profile-a", MaxConcurrentActivities = 3, @@ -208,7 +206,6 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_Reopens // Arrange OnDemandSandboxWorkerRuntimeOptions options = new() { - Mode = OnDemandSandboxMode.OnDemandSandboxInclude, TaskHub = TaskHub, WorkerProfileId = "profile-a", MaxConcurrentActivities = 3, @@ -247,7 +244,6 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_Reopens // Arrange OnDemandSandboxWorkerRuntimeOptions options = new() { - Mode = OnDemandSandboxMode.OnDemandSandboxInclude, TaskHub = TaskHub, WorkerProfileId = "profile-a", MaxConcurrentActivities = 3, @@ -315,7 +311,6 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_Applies // Arrange OnDemandSandboxWorkerRuntimeOptions options = new() { - Mode = OnDemandSandboxMode.OnDemandSandboxInclude, TaskHub = TaskHub, WorkerProfileId = "profile-a", MaxConcurrentActivities = 3, @@ -353,7 +348,6 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_StopAsy // Arrange OnDemandSandboxWorkerRuntimeOptions options = new() { - Mode = OnDemandSandboxMode.OnDemandSandboxInclude, TaskHub = TaskHub, WorkerProfileId = "profile-a", MaxConcurrentActivities = 3, @@ -393,6 +387,7 @@ public async Task UseSandboxWorker_ConfiguresRegisteredActivityWorkerFilter() // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "Sandbox"); using EnvironmentVariableScope maxActivities = new("DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES", "3"); ServiceCollection services = new(); services.Configure( @@ -424,6 +419,7 @@ public async Task UseSandboxWorker_WithNoRegisteredActivities_FailsWhenWorkerFil // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "Sandbox"); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); @@ -578,6 +574,62 @@ public void UseSandboxWorker_MissingInjectedTaskHub_Throws() .WithMessage("DTS_TASK_HUB must be injected by DTS for on-demand sandbox workers."); } + [Fact] + public async Task UseSandboxWorker_MissingInjectedSubstrate_ThrowsWhenWorkerOptionsAreResolved() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", null); + ServiceCollection services = new(); + services.Configure( + Options.DefaultName, + registry => registry.AddActivityFunc(new TaskName("RemoteHello"), (_, input) => input)); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + mockBuilder.Object.UseSandboxWorker(); + await using ServiceProvider provider = services.BuildServiceProvider(); + + // Act + Action action = () => provider + .GetRequiredService>() + .Get(Options.DefaultName); + + // Assert + action.Should().Throw() + .WithMessage("DTS_SUBSTRATE must be injected by DTS for on-demand sandbox workers."); + } + + [Fact] + public async Task UseSandboxWorker_InvalidInjectedSubstrate_ThrowsWhenWorkerOptionsAreResolved() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "ContainerApp"); + ServiceCollection services = new(); + services.Configure( + Options.DefaultName, + registry => registry.AddActivityFunc(new TaskName("RemoteHello"), (_, input) => input)); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + mockBuilder.Object.UseSandboxWorker(); + await using ServiceProvider provider = services.BuildServiceProvider(); + + // Act + Action action = () => provider + .GetRequiredService>() + .Get(Options.DefaultName); + + // Assert + action.Should().Throw() + .WithMessage("DTS_SUBSTRATE must be 'Sandbox' or 'AcaSessionPool' for on-demand sandbox workers."); + } + sealed class FakeOnDemandSandboxActivitiesTransport : IOnDemandSandboxActivitiesTransport { readonly Queue queuedSessions = new(); From 762c5aeef3643e130cbbb9f4ceded6334d6e5370 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 10 Jun 2026 17:03:14 -0700 Subject: [PATCH 57/81] Fail fast on invalid sandbox profile declarations Throw when a type annotated with OnDemandSandboxWorkerProfile does not implement ISandboxWorkerProfile instead of silently skipping the declaration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...emandSandboxActivityDeclarationResolver.cs | 20 +++++++++++++++---- .../OnDemandSandboxActivitiesClientTests.cs | 15 ++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs b/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs index d245795c..0eb9406b 100644 --- a/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs +++ b/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs @@ -33,6 +33,19 @@ public static IReadOnlyList ResolveDeclarations(string t return declarations; } + /// + /// Validates that a profile type can configure on-demand sandbox declarations. + /// + /// The profile type. + internal static void ValidateProfileType(Type profileType) + { + if (!typeof(ISandboxWorkerProfile).IsAssignableFrom(profileType)) + { + throw new InvalidOperationException( + $"On-demand sandbox worker profile '{profileType.FullName}' must implement {nameof(ISandboxWorkerProfile)}."); + } + } + static ProfileMetadata[] ScanProfiles() { Dictionary profiles = new(StringComparer.Ordinal); @@ -43,6 +56,8 @@ static ProfileMetadata[] ScanProfiles() continue; } + ValidateProfileType(type); + if (profiles.ContainsKey(profile.WorkerProfileId)) { throw new InvalidOperationException($"On-demand sandbox worker profile '{profile.WorkerProfileId}' is declared more than once."); @@ -70,10 +85,7 @@ static OnDemandSandboxOptions CreateOptions( static void ConfigureProfile(Type profileType, OnDemandSandboxOptions options) { - if (!typeof(ISandboxWorkerProfile).IsAssignableFrom(profileType)) - { - return; - } + ValidateProfileType(profileType); object? instance = Activator.CreateInstance(profileType, nonPublic: true) ?? throw new InvalidOperationException($"On-demand sandbox worker profile '{profileType.FullName}' could not be created."); diff --git a/test/Client/AzureManaged.Tests/OnDemandSandboxActivitiesClientTests.cs b/test/Client/AzureManaged.Tests/OnDemandSandboxActivitiesClientTests.cs index 39eb47af..9dfe7e46 100644 --- a/test/Client/AzureManaged.Tests/OnDemandSandboxActivitiesClientTests.cs +++ b/test/Client/AzureManaged.Tests/OnDemandSandboxActivitiesClientTests.cs @@ -169,6 +169,17 @@ public void OnDemandSandboxActivityDeclarationResolver_ResolveDeclarations_UsesW declaration.Cmd.Should().BeEmpty(); } + [Fact] + public void OnDemandSandboxActivityDeclarationResolver_ValidateProfileType_RequiresProfileInterface() + { + // Act + Action action = () => OnDemandSandboxActivityDeclarationResolver.ValidateProfileType(typeof(ProfileWithoutInterface)); + + // Assert + action.Should().Throw() + .WithMessage($"*{nameof(ISandboxWorkerProfile)}*"); + } + [Fact] public async Task AddDurableTaskSchedulerOnDemandSandboxActivitiesClient_UsesConfiguredDurableTaskClientInvoker() { @@ -346,6 +357,10 @@ public void Configure(OnDemandSandboxOptions options) } } + sealed class ProfileWithoutInterface + { + } + sealed class EnvironmentVariableScope : IDisposable { readonly string name; From 1df3461cffbd62a1d2477d181d8d65d65ce056d2 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 10 Jun 2026 17:52:32 -0700 Subject: [PATCH 58/81] Simplify on-demand sandbox worker cleanup Inline the worker profile environment override, remove declaration state from the worker test transport, and drop the redundant no-activity registration skip log. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...chedulerOnDemandSandboxWorkerExtensions.cs | 15 +++++--------- .../AzureManaged/OnDemandSandbox/Logs.cs | 6 ------ ...ActivityWorkerRegistrationHostedService.cs | 7 ------- .../OnDemandSandboxActivitiesTests.cs | 20 ++----------------- 4 files changed, 7 insertions(+), 41 deletions(-) diff --git a/src/Worker/AzureManaged/OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs b/src/Worker/AzureManaged/OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs index 79a2a488..6f1170b4 100644 --- a/src/Worker/AzureManaged/OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs +++ b/src/Worker/AzureManaged/OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs @@ -175,20 +175,15 @@ static void ApplyWorkerEnvironmentOverrides(OnDemandSandboxWorkerRuntimeOptions { ValidateOnDemandSandboxWorkerSubstrate(GetRequiredEnvironmentVariable("DTS_SUBSTRATE")); - ApplyWorkerProfileEnvironmentOverride(profile => options.WorkerProfileId = profile); - - if (int.TryParse(Environment.GetEnvironmentVariable("DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES"), out int maxActivities) && maxActivities > 0) + string? workerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID"); + if (!string.IsNullOrWhiteSpace(workerProfileId)) { - options.MaxConcurrentActivities = maxActivities; + options.WorkerProfileId = workerProfileId.Trim(); } - } - static void ApplyWorkerProfileEnvironmentOverride(Action setWorkerProfileId) - { - string? workerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID"); - if (!string.IsNullOrWhiteSpace(workerProfileId)) + if (int.TryParse(Environment.GetEnvironmentVariable("DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES"), out int maxActivities) && maxActivities > 0) { - setWorkerProfileId(workerProfileId.Trim()); + options.MaxConcurrentActivities = maxActivities; } } diff --git a/src/Worker/AzureManaged/OnDemandSandbox/Logs.cs b/src/Worker/AzureManaged/OnDemandSandbox/Logs.cs index b3e9022b..0a78e5d0 100644 --- a/src/Worker/AzureManaged/OnDemandSandbox/Logs.cs +++ b/src/Worker/AzureManaged/OnDemandSandbox/Logs.cs @@ -11,12 +11,6 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; /// static partial class Logs { - [LoggerMessage( - EventId = 700, - Level = LogLevel.Information, - Message = "No on-demand sandbox activities discovered for worker hub={Hub}; skipping live registration")] - public static partial void NoOnDemandSandboxActivitiesForWorkerRegistration(ILogger logger, string hub); - [LoggerMessage( EventId = 701, Level = LogLevel.Information, diff --git a/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs b/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs index bbc624fe..0b923d51 100644 --- a/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs +++ b/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs @@ -60,13 +60,6 @@ public OnDemandSandboxActivityWorkerRegistrationHostedService( public Task StartAsync(CancellationToken cancellationToken) { string[] activityNames = OnDemandSandboxActivityMetadata.ResolveActivityNames(this.registeredActivityNames); - if (activityNames.Length == 0) - { - Logs.NoOnDemandSandboxActivitiesForWorkerRegistration(this.logger, this.options.TaskHub); - this.pump = Task.CompletedTask; - return Task.CompletedTask; - } - CancellationTokenSource registrationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); Task registrationPump = Task.Run( () => this.RunRegistrationLoopAsync(activityNames.Length, registrationCts.Token), diff --git a/test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs b/test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs index 536c1e25..29dea0bc 100644 --- a/test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs +++ b/test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs @@ -634,14 +634,6 @@ sealed class FakeOnDemandSandboxActivitiesTransport : IOnDemandSandboxActivities { readonly Queue queuedSessions = new(); - public int TransientDeclarationFailures { get; init; } - - public int DeclarationAttempts { get; private set; } - - public List Declarations { get; } = []; - - public List DeclarationTaskHubs { get; } = []; - public List SessionTaskHubs { get; } = []; public List Sessions { get; } = []; @@ -655,15 +647,7 @@ public Task DeclareOnDemandSandboxActi string taskHub, CancellationToken cancellationToken) { - this.DeclarationAttempts++; - if (this.DeclarationAttempts <= this.TransientDeclarationFailures) - { - throw new RpcException(new Status(StatusCode.Unavailable, "transient")); - } - - this.DeclarationTaskHubs.Add(taskHub); - this.Declarations.Add(declaration.Clone()); - return Task.FromResult(new OnDemandSandboxActivityDeclarationResult()); + throw new NotSupportedException(); } public Task RemoveOnDemandSandboxActivityDeclarationAsync( @@ -671,7 +655,7 @@ public Task RemoveOnDemandSandbo string taskHub, CancellationToken cancellationToken) { - return Task.FromResult(new RemoveOnDemandSandboxActivityDeclarationResult()); + throw new NotSupportedException(); } public IOnDemandSandboxActivityWorkerSession OpenOnDemandSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken) From 98a0e1fb1e75a86f67b9439c440e4367213345fd Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 11 Jun 2026 08:32:51 -0700 Subject: [PATCH 59/81] Fix shared AzureManaged test protobuf reference --- test/Shared/AzureManaged.Tests/Shared.AzureManaged.Tests.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Shared/AzureManaged.Tests/Shared.AzureManaged.Tests.csproj b/test/Shared/AzureManaged.Tests/Shared.AzureManaged.Tests.csproj index 4599449b..5d7f0abd 100644 --- a/test/Shared/AzureManaged.Tests/Shared.AzureManaged.Tests.csproj +++ b/test/Shared/AzureManaged.Tests/Shared.AzureManaged.Tests.csproj @@ -17,6 +17,7 @@ + From 78e3d52ef864ffc8d4acbc777335f5e0c639f0f2 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 11 Jun 2026 08:58:04 -0700 Subject: [PATCH 60/81] Remove unnecessary Grpc.Core references --- src/Client/AzureManaged/Client.AzureManaged.csproj | 1 - src/Worker/AzureManaged/Worker.AzureManaged.csproj | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Client/AzureManaged/Client.AzureManaged.csproj b/src/Client/AzureManaged/Client.AzureManaged.csproj index 8f3ffa99..7dd459f3 100644 --- a/src/Client/AzureManaged/Client.AzureManaged.csproj +++ b/src/Client/AzureManaged/Client.AzureManaged.csproj @@ -12,7 +12,6 @@ - diff --git a/src/Worker/AzureManaged/Worker.AzureManaged.csproj b/src/Worker/AzureManaged/Worker.AzureManaged.csproj index 49da9eef..e433b192 100644 --- a/src/Worker/AzureManaged/Worker.AzureManaged.csproj +++ b/src/Worker/AzureManaged/Worker.AzureManaged.csproj @@ -12,7 +12,6 @@ - From 278f025146ba9a14d9d01386247bb685b476719a Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 11 Jun 2026 09:10:47 -0700 Subject: [PATCH 61/81] Include on-demand sandbox proto in refresh script --- src/Grpc/refresh-protos.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Grpc/refresh-protos.ps1 b/src/Grpc/refresh-protos.ps1 index a91393a4..e1f2967f 100644 --- a/src/Grpc/refresh-protos.ps1 +++ b/src/Grpc/refresh-protos.ps1 @@ -18,7 +18,8 @@ $commitId = $commitDetails.sha # These are the proto files we need to download from the durabletask-protobuf repository. $protoFileNames = @( - "orchestrator_service.proto" + "orchestrator_service.proto", + "on_demand_sandbox_activities_service.proto" ) # Download each proto file to the local directory using the above commit ID From 5c7fa1763229e8ff09ed0c1f96edfd17cb4de422 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 11 Jun 2026 09:17:29 -0700 Subject: [PATCH 62/81] Address on-demand sandbox review comments --- samples/on-demand-sandbox/README.md | 2 +- samples/on-demand-sandbox/main-app/appsettings.json | 2 +- ...DemandSandboxActivityWorkerRegistrationHostedService.cs | 7 ++++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/samples/on-demand-sandbox/README.md b/samples/on-demand-sandbox/README.md index bf6ae193..040fa2fe 100644 --- a/samples/on-demand-sandbox/README.md +++ b/samples/on-demand-sandbox/README.md @@ -43,7 +43,7 @@ Update `main-app/appsettings.json` with your scheduler endpoint and task hub: { "OnDemandSandboxSample": { "EndpointAddress": "https://", - "TaskHubName": "OnDemandSandboxPocHub" + "TaskHubName": "" } } ``` diff --git a/samples/on-demand-sandbox/main-app/appsettings.json b/samples/on-demand-sandbox/main-app/appsettings.json index 78415af8..89e35344 100644 --- a/samples/on-demand-sandbox/main-app/appsettings.json +++ b/samples/on-demand-sandbox/main-app/appsettings.json @@ -1,6 +1,6 @@ { "OnDemandSandboxSample": { "EndpointAddress": "https://", - "TaskHubName": "OnDemandSandboxPocHub" + "TaskHubName": "" } } diff --git a/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs b/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs index 0b923d51..557b4b14 100644 --- a/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs +++ b/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs @@ -237,7 +237,7 @@ async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancell retryDelay, cancellationToken).ConfigureAwait(false); } - catch (Exception ex) + catch (Exception ex) when (!IsFatalException(ex)) { Logs.OnDemandSandboxActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); this.lifetime?.StopApplication(); @@ -397,4 +397,9 @@ async Task DelayBeforeReconnectAsync(TimeSpan retryDelay, CancellationToken canc await Task.Delay(jitteredDelay, cancellationToken).ConfigureAwait(false); } } + + static bool IsFatalException(Exception ex) => ex is OutOfMemoryException + or StackOverflowException + or AccessViolationException + or ThreadAbortException; } From 3f43978d6f0efb4bee9bccde153c9ddb7691f687 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 11 Jun 2026 10:02:51 -0700 Subject: [PATCH 63/81] Split on-demand sandbox into AzureManaged packages --- CHANGELOG.md | 2 +- Microsoft.DurableTask.sln | 45 +++++++++++++++++++ eng/publish/publish.yml | 14 +++--- .../main-app/main-app.csproj | 2 + .../remote-worker/remote-worker.csproj | 1 + ...Client.AzureManaged.OnDemandSandbox.csproj | 29 ++++++++++++ .../ISandboxWorkerProfile.cs | 0 .../OnDemandSandboxActivitiesClient.cs | 0 ...vitiesClientServiceCollectionExtensions.cs | 0 ...DemandSandboxActivityDeclarationBuilder.cs | 0 ...emandSandboxActivityDeclarationResolver.cs | 0 .../OnDemandSandboxOptions.cs | 0 .../OnDemandSandboxWorkerProfileAttribute.cs | 0 .../OnDemandSandboxActivitiesGrpcTransport.cs | 0 .../OnDemandSandboxActivityMetadata.cs | 0 ...chedulerOnDemandSandboxWorkerExtensions.cs | 0 .../Logs.cs | 0 .../OnDemandSandboxActivityTracker.cs | 0 ...ActivityWorkerRegistrationHostedService.cs | 0 .../OnDemandSandboxWorkerMessageBuilder.cs | 0 .../OnDemandSandboxWorkerRuntimeOptions.cs | 0 ...Worker.AzureManaged.OnDemandSandbox.csproj | 31 +++++++++++++ .../Client.AzureManaged.Tests.csproj | 1 + .../Worker.AzureManaged.Tests.csproj | 1 + 24 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 src/Client/AzureManaged.OnDemandSandbox/Client.AzureManaged.OnDemandSandbox.csproj rename src/Client/{AzureManaged/OnDemandSandbox => AzureManaged.OnDemandSandbox}/ISandboxWorkerProfile.cs (100%) rename src/Client/{AzureManaged/OnDemandSandbox => AzureManaged.OnDemandSandbox}/OnDemandSandboxActivitiesClient.cs (100%) rename src/Client/{AzureManaged/OnDemandSandbox => AzureManaged.OnDemandSandbox}/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs (100%) rename src/Client/{AzureManaged/OnDemandSandbox => AzureManaged.OnDemandSandbox}/OnDemandSandboxActivityDeclarationBuilder.cs (100%) rename src/Client/{AzureManaged/OnDemandSandbox => AzureManaged.OnDemandSandbox}/OnDemandSandboxActivityDeclarationResolver.cs (100%) rename src/Client/{AzureManaged/OnDemandSandbox => AzureManaged.OnDemandSandbox}/OnDemandSandboxOptions.cs (100%) rename src/Client/{AzureManaged/OnDemandSandbox => AzureManaged.OnDemandSandbox}/OnDemandSandboxWorkerProfileAttribute.cs (100%) rename src/Shared/{AzureManaged/OnDemandSandbox => AzureManaged.OnDemandSandbox}/OnDemandSandboxActivitiesGrpcTransport.cs (100%) rename src/Shared/{AzureManaged/OnDemandSandbox => AzureManaged.OnDemandSandbox}/OnDemandSandboxActivityMetadata.cs (100%) rename src/Worker/{AzureManaged/OnDemandSandbox => AzureManaged.OnDemandSandbox}/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs (100%) rename src/Worker/{AzureManaged/OnDemandSandbox => AzureManaged.OnDemandSandbox}/Logs.cs (100%) rename src/Worker/{AzureManaged/OnDemandSandbox => AzureManaged.OnDemandSandbox}/OnDemandSandboxActivityTracker.cs (100%) rename src/Worker/{AzureManaged/OnDemandSandbox => AzureManaged.OnDemandSandbox}/OnDemandSandboxActivityWorkerRegistrationHostedService.cs (100%) rename src/Worker/{AzureManaged/OnDemandSandbox => AzureManaged.OnDemandSandbox}/OnDemandSandboxWorkerMessageBuilder.cs (100%) rename src/Worker/{AzureManaged/OnDemandSandbox => AzureManaged.OnDemandSandbox}/OnDemandSandboxWorkerRuntimeOptions.cs (100%) create mode 100644 src/Worker/AzureManaged.OnDemandSandbox/Worker.AzureManaged.OnDemandSandbox.csproj diff --git a/CHANGELOG.md b/CHANGELOG.md index 84ae5e55..aab35404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Moved private preview on-demand sandbox APIs into the existing Azure Managed client and worker packages, with shared internal transport code under the Azure Managed shared sources. +- Split private preview on-demand sandbox APIs into opt-in `Microsoft.DurableTask.Client.AzureManaged.OnDemandSandbox` and `Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox` packages. - Updated private preview on-demand sandbox worker profile declarations to use `OnDemandSandboxOptions.AddActivity(...)`, and updated the on-demand sandbox sample to share activity name constants between the main app and remote worker. - Added SDK-side validation for private preview on-demand sandbox CPU and memory resource quantities. diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index 4794ce2a..d49f3744 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -131,6 +131,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "main-app", "samples\on-dema EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "remote-worker", "samples\on-demand-sandbox\remote-worker\remote-worker.csproj", "{B7069604-DD97-4115-8B30-FC1D4C0E6D43}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged.OnDemandSandbox", "AzureManaged.OnDemandSandbox", "{28648169-70E4-D0BA-4357-338A556A7DA8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.AzureManaged.OnDemandSandbox", "src\Client\AzureManaged.OnDemandSandbox\Client.AzureManaged.OnDemandSandbox.csproj", "{A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{D4587EC0-1B16-8420-7502-A967139249D4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged.OnDemandSandbox", "AzureManaged.OnDemandSandbox", "{9686B8F9-2644-6C9B-E567-55B0471E4584}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Worker.AzureManaged.OnDemandSandbox", "src\Worker\AzureManaged.OnDemandSandbox\Worker.AzureManaged.OnDemandSandbox.csproj", "{C1995163-1DCE-405D-BE82-8B4B2584893E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{53193780-CD18-2643-6953-C26F59EAEDF5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Grpc", "Grpc", "{3B8F957E-7773-4C0C-ACD7-91A1591D9312}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -801,6 +815,30 @@ Global {B7069604-DD97-4115-8B30-FC1D4C0E6D43}.Release|x64.Build.0 = Release|Any CPU {B7069604-DD97-4115-8B30-FC1D4C0E6D43}.Release|x86.ActiveCfg = Release|Any CPU {B7069604-DD97-4115-8B30-FC1D4C0E6D43}.Release|x86.Build.0 = Release|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Debug|x64.ActiveCfg = Debug|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Debug|x64.Build.0 = Debug|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Debug|x86.ActiveCfg = Debug|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Debug|x86.Build.0 = Debug|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Release|Any CPU.Build.0 = Release|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Release|x64.ActiveCfg = Release|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Release|x64.Build.0 = Release|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Release|x86.ActiveCfg = Release|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Release|x86.Build.0 = Release|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Debug|x64.ActiveCfg = Debug|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Debug|x64.Build.0 = Debug|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Debug|x86.ActiveCfg = Debug|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Debug|x86.Build.0 = Debug|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Release|Any CPU.Build.0 = Release|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Release|x64.ActiveCfg = Release|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Release|x64.Build.0 = Release|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Release|x86.ActiveCfg = Release|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -866,6 +904,13 @@ Global {F0DC6D16-C9BC-4804-BBAF-84C050D5E279} = {D422B5FD-8E3C-6588-ACD1-DEFAB429269C} {0CB44F0A-3483-4052-A49F-D4E6F140741C} = {D422B5FD-8E3C-6588-ACD1-DEFAB429269C} {B7069604-DD97-4115-8B30-FC1D4C0E6D43} = {D422B5FD-8E3C-6588-ACD1-DEFAB429269C} + {28648169-70E4-D0BA-4357-338A556A7DA8} = {1C217BB2-CE16-41CC-9D47-0FC0DB60BDB3} + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056} = {28648169-70E4-D0BA-4357-338A556A7DA8} + {D4587EC0-1B16-8420-7502-A967139249D4} = {1C217BB2-CE16-41CC-9D47-0FC0DB60BDB3} + {9686B8F9-2644-6C9B-E567-55B0471E4584} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5} + {C1995163-1DCE-405D-BE82-8B4B2584893E} = {9686B8F9-2644-6C9B-E567-55B0471E4584} + {53193780-CD18-2643-6953-C26F59EAEDF5} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5} + {3B8F957E-7773-4C0C-ACD7-91A1591D9312} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71} diff --git a/eng/publish/publish.yml b/eng/publish/publish.yml index b77ead2d..561c85ec 100644 --- a/eng/publish/publish.yml +++ b/eng/publish/publish.yml @@ -137,7 +137,7 @@ extends: # the packages to push pattern explicitly excludes: # - 'Microsoft.DurableTask.Client.Grpc' # - 'Microsoft.DurableTask.Client.OrchestrationServiceClientShim' - # - 'Microsoft.DurableTask.Client.AzureManaged' + # - 'Microsoft.DurableTask.Client.AzureManaged.*' # which are pushed by their respective jobs packagesToPush: '$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Client.*.nupkg;!$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Client.Grpc.*.nupkg;!$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Client.OrchestrationServiceClientShim.*.nupkg;!$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Client.AzureManaged.*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' # Despite this being a custom command, we need to keep this for 1ES validation packageParentPath: $(System.DefaultWorkingDirectory) # This needs to be set to some prefix of the `packagesToPush` parameter. Apparently it helps with SDL tooling @@ -304,9 +304,9 @@ extends: packagesToPush: '$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Worker.Grpc.*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' # Despite this being a custom command, we need to keep this for 1ES validation packageParentPath: $(System.DefaultWorkingDirectory) # This needs to be set to some prefix of the `packagesToPush` parameter. Apparently it helps with SDL tooling - # NuGet release (Microsoft.DurableTask.Client.AzureManaged) + # NuGet release (Microsoft.DurableTask.Client.AzureManaged.*) - job: nugetRelease_Microsoft_DurableTask_Client_AzureManaged - displayName: NuGet Release (Microsoft.DurableTask.Client.AzureManaged) + displayName: NuGet Release (Microsoft.DurableTask.Client.AzureManaged.*) dependsOn: nugetApproval condition: succeeded('nugetApproval') # nuget packages need to be on ADO first templateContext: @@ -319,7 +319,7 @@ extends: targetPath: $(System.DefaultWorkingDirectory)/drop steps: - task: 1ES.PublishNuget@1 - displayName: 'NuGet push (Microsoft.DurableTask.Client.AzureManaged)' + displayName: 'NuGet push (Microsoft.DurableTask.Client.AzureManaged.*)' inputs: command: push nuGetFeedType: external @@ -327,9 +327,9 @@ extends: packagesToPush: '$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Client.AzureManaged.*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' # Despite this being a custom command, we need to keep this for 1ES validation packageParentPath: $(System.DefaultWorkingDirectory) # This needs to be set to some prefix of the `packagesToPush` parameter. Apparently it helps with SDL tooling - # NuGet release (Microsoft.DurableTask.Worker.AzureManaged) + # NuGet release (Microsoft.DurableTask.Worker.AzureManaged.*) - job: nugetRelease_Microsoft_DurableTask_Worker_AzureManaged - displayName: NuGet Release (Microsoft.DurableTask.Worker.AzureManaged) + displayName: NuGet Release (Microsoft.DurableTask.Worker.AzureManaged.*) dependsOn: nugetApproval condition: succeeded('nugetApproval') # nuget packages need to be on ADO first templateContext: @@ -342,7 +342,7 @@ extends: targetPath: $(System.DefaultWorkingDirectory)/drop steps: - task: 1ES.PublishNuget@1 - displayName: 'NuGet push (Microsoft.DurableTask.Worker.AzureManaged)' + displayName: 'NuGet push (Microsoft.DurableTask.Worker.AzureManaged.*)' inputs: command: push nuGetFeedType: external diff --git a/samples/on-demand-sandbox/main-app/main-app.csproj b/samples/on-demand-sandbox/main-app/main-app.csproj index d4d7e76d..88ce55e1 100644 --- a/samples/on-demand-sandbox/main-app/main-app.csproj +++ b/samples/on-demand-sandbox/main-app/main-app.csproj @@ -15,7 +15,9 @@ + + diff --git a/samples/on-demand-sandbox/remote-worker/remote-worker.csproj b/samples/on-demand-sandbox/remote-worker/remote-worker.csproj index e25c2bc9..b39f4767 100644 --- a/samples/on-demand-sandbox/remote-worker/remote-worker.csproj +++ b/samples/on-demand-sandbox/remote-worker/remote-worker.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Client/AzureManaged.OnDemandSandbox/Client.AzureManaged.OnDemandSandbox.csproj b/src/Client/AzureManaged.OnDemandSandbox/Client.AzureManaged.OnDemandSandbox.csproj new file mode 100644 index 00000000..42c1fb11 --- /dev/null +++ b/src/Client/AzureManaged.OnDemandSandbox/Client.AzureManaged.OnDemandSandbox.csproj @@ -0,0 +1,29 @@ + + + + net6.0;net8.0;net10.0 + Azure Managed on-demand sandbox activity management extensions for the Durable Task Framework client. + Microsoft.DurableTask.Client.AzureManaged.OnDemandSandbox + Microsoft.DurableTask + true + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Client/AzureManaged/OnDemandSandbox/ISandboxWorkerProfile.cs b/src/Client/AzureManaged.OnDemandSandbox/ISandboxWorkerProfile.cs similarity index 100% rename from src/Client/AzureManaged/OnDemandSandbox/ISandboxWorkerProfile.cs rename to src/Client/AzureManaged.OnDemandSandbox/ISandboxWorkerProfile.cs diff --git a/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivitiesClient.cs b/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClient.cs similarity index 100% rename from src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivitiesClient.cs rename to src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClient.cs diff --git a/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs b/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs similarity index 100% rename from src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs rename to src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs diff --git a/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs b/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs similarity index 100% rename from src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs rename to src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs diff --git a/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs b/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs similarity index 100% rename from src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs rename to src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs diff --git a/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxOptions.cs b/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxOptions.cs similarity index 100% rename from src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxOptions.cs rename to src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxOptions.cs diff --git a/src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs b/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs similarity index 100% rename from src/Client/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs rename to src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs diff --git a/src/Shared/AzureManaged/OnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs b/src/Shared/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs similarity index 100% rename from src/Shared/AzureManaged/OnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs rename to src/Shared/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs diff --git a/src/Shared/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityMetadata.cs b/src/Shared/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityMetadata.cs similarity index 100% rename from src/Shared/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityMetadata.cs rename to src/Shared/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityMetadata.cs diff --git a/src/Worker/AzureManaged/OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs b/src/Worker/AzureManaged.OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs similarity index 100% rename from src/Worker/AzureManaged/OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs rename to src/Worker/AzureManaged.OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs diff --git a/src/Worker/AzureManaged/OnDemandSandbox/Logs.cs b/src/Worker/AzureManaged.OnDemandSandbox/Logs.cs similarity index 100% rename from src/Worker/AzureManaged/OnDemandSandbox/Logs.cs rename to src/Worker/AzureManaged.OnDemandSandbox/Logs.cs diff --git a/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityTracker.cs b/src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityTracker.cs similarity index 100% rename from src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityTracker.cs rename to src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityTracker.cs diff --git a/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs b/src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs similarity index 100% rename from src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs rename to src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs diff --git a/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs b/src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs similarity index 100% rename from src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs rename to src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs diff --git a/src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs b/src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs similarity index 100% rename from src/Worker/AzureManaged/OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs rename to src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs diff --git a/src/Worker/AzureManaged.OnDemandSandbox/Worker.AzureManaged.OnDemandSandbox.csproj b/src/Worker/AzureManaged.OnDemandSandbox/Worker.AzureManaged.OnDemandSandbox.csproj new file mode 100644 index 00000000..c1c31099 --- /dev/null +++ b/src/Worker/AzureManaged.OnDemandSandbox/Worker.AzureManaged.OnDemandSandbox.csproj @@ -0,0 +1,31 @@ + + + + net6.0;net8.0;net10.0 + Azure Managed on-demand sandbox activity worker extensions for the Durable Task Framework worker. + Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox + Microsoft.DurableTask + true + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj b/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj index d890a619..2579a784 100644 --- a/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj +++ b/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj b/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj index 53fd1b1f..ecd3f7d6 100644 --- a/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj +++ b/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj @@ -11,6 +11,7 @@ + From 6cf9f72b10dfef614fe48e9f2e83a4ff84f223b7 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 11 Jun 2026 10:50:08 -0700 Subject: [PATCH 64/81] Refactor on-demand sandbox declaration provider --- .../OnDemandSandboxActivitiesClient.cs | 9 ++++++--- ...ivitiesClientServiceCollectionExtensions.cs | 10 ++++++++-- ...nDemandSandboxActivityDeclarationBuilder.cs | 12 ++++++++++++ ...emandSandboxActivityDeclarationProvider.cs} | 18 +++++++++++++----- .../OnDemandSandboxActivitiesClientTests.cs | 14 +++++++++----- 5 files changed, 48 insertions(+), 15 deletions(-) rename src/Client/AzureManaged.OnDemandSandbox/{OnDemandSandboxActivityDeclarationResolver.cs => OnDemandSandboxActivityDeclarationProvider.cs} (88%) diff --git a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClient.cs b/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClient.cs index 163f4d48..b58f6bf2 100644 --- a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClient.cs +++ b/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClient.cs @@ -12,6 +12,7 @@ namespace Microsoft.DurableTask.Client.AzureManaged; public sealed class OnDemandSandboxActivitiesClient { readonly IOnDemandSandboxActivitiesTransport transport; + readonly OnDemandSandboxActivityDeclarationProvider declarationProvider; readonly string taskHub; /// @@ -19,11 +20,14 @@ public sealed class OnDemandSandboxActivitiesClient /// /// The transport used to call DTS on-demand sandbox management operations. /// The task hub whose declarations should be sent to DTS. + /// The declaration provider. internal OnDemandSandboxActivitiesClient( IOnDemandSandboxActivitiesTransport transport, - string taskHub) + string taskHub, + OnDemandSandboxActivityDeclarationProvider declarationProvider) { this.transport = Check.NotNull(transport); + this.declarationProvider = Check.NotNull(declarationProvider); this.taskHub = string.IsNullOrWhiteSpace(taskHub) ? throw new ArgumentException("Task hub name is required.", nameof(taskHub)) : taskHub.Trim(); @@ -36,8 +40,7 @@ internal OnDemandSandboxActivitiesClient( /// A task that completes when DTS accepts all declarations. public async Task EnableOnDemandSandboxActivitiesAsync(CancellationToken cancellation = default) { - IReadOnlyList declarations = - OnDemandSandboxActivityDeclarationResolver.ResolveDeclarations(this.taskHub); + IReadOnlyList declarations = this.declarationProvider.ResolveDeclarations(this.taskHub); foreach (OnDemandSandboxOptions options in declarations) { string[] activityNames = OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames); diff --git a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs b/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs index 0704537f..c44d9c3c 100644 --- a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs +++ b/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.DurableTask.AzureManaged.Internal; using Microsoft.DurableTask.Client.Grpc; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; @@ -36,6 +37,8 @@ public static IServiceCollection AddDurableTaskSchedulerOnDemandSandboxActivitie ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(clientName); + services.TryAddSingleton(); + services.AddSingleton(provider => { DurableTaskSchedulerClientOptions schedulerOptions = provider @@ -44,6 +47,7 @@ public static IServiceCollection AddDurableTaskSchedulerOnDemandSandboxActivitie GrpcDurableTaskClientOptions options = provider .GetRequiredService>() .Get(clientName); + OnDemandSandboxActivityDeclarationProvider declarationProvider = provider.GetRequiredService(); if (options.CallInvoker is { } callInvoker) { @@ -51,7 +55,8 @@ public static IServiceCollection AddDurableTaskSchedulerOnDemandSandboxActivitie new OnDemandSandboxActivitiesGrpcTransport( new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker), attachTaskHubMetadata: false), - schedulerOptions.TaskHubName); + schedulerOptions.TaskHubName, + declarationProvider); } if (options.Channel is GrpcChannel channel) @@ -60,7 +65,8 @@ public static IServiceCollection AddDurableTaskSchedulerOnDemandSandboxActivitie new OnDemandSandboxActivitiesGrpcTransport( new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(channel.CreateCallInvoker()), attachTaskHubMetadata: false), - schedulerOptions.TaskHubName); + schedulerOptions.TaskHubName, + declarationProvider); } throw new InvalidOperationException("DTS on-demand sandbox activity management requires a configured Durable Task Scheduler client."); diff --git a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs b/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs index 1e8ef237..2330ccd3 100644 --- a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs +++ b/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs @@ -69,11 +69,23 @@ public static Proto.OnDemandSandboxActivityDeclaration BuildDeclaration( return declaration; } + /// + /// Normalizes a worker profile ID and throws with the supplied message if it is missing. + /// + /// The worker profile ID value. + /// The error message to use when the value is missing. + /// The normalized worker profile ID. internal static string NormalizeWorkerProfileId(string value, string errorMessage) { return OnDemandSandboxActivityMetadata.NormalizeRequired(value, errorMessage); } + /// + /// Normalizes a required string and throws with the supplied message if it is missing. + /// + /// The value. + /// The error message to use when the value is missing. + /// The normalized value. internal static string NormalizeRequired(string value, string errorMessage) { return OnDemandSandboxActivityMetadata.NormalizeRequired(value, errorMessage); diff --git a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs b/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationProvider.cs similarity index 88% rename from src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs rename to src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationProvider.cs index 0eb9406b..e5e4fc94 100644 --- a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationResolver.cs +++ b/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationProvider.cs @@ -7,24 +7,32 @@ namespace Microsoft.DurableTask.Client.AzureManaged; /// -/// Resolves on-demand sandbox activity declarations from worker profile configuration. +/// Provides on-demand sandbox activity declarations from worker profile configuration. /// -static class OnDemandSandboxActivityDeclarationResolver +sealed class OnDemandSandboxActivityDeclarationProvider { - static readonly Lazy Profiles = new(ScanProfiles, LazyThreadSafetyMode.ExecutionAndPublication); + readonly Lazy profiles; + + /// + /// Initializes a new instance of the class. + /// + public OnDemandSandboxActivityDeclarationProvider() + { + this.profiles = new Lazy(ScanProfiles, LazyThreadSafetyMode.ExecutionAndPublication); + } /// /// Resolves on-demand sandbox declarations for the specified task hub. /// /// The task hub name. /// The resolved on-demand sandbox declaration options. - public static IReadOnlyList ResolveDeclarations(string taskHub) + public IReadOnlyList ResolveDeclarations(string taskHub) { string normalizedTaskHub = string.IsNullOrWhiteSpace(taskHub) ? throw new InvalidOperationException("On-demand sandbox activity declaration requires a task hub name.") : taskHub.Trim(); - OnDemandSandboxOptions[] declarations = Profiles.Value + OnDemandSandboxOptions[] declarations = this.profiles.Value .Select(profile => CreateOptions(normalizedTaskHub, profile)) .Where(static options => OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames).Length > 0) .ToArray(); diff --git a/test/Client/AzureManaged.Tests/OnDemandSandboxActivitiesClientTests.cs b/test/Client/AzureManaged.Tests/OnDemandSandboxActivitiesClientTests.cs index 9dfe7e46..074c9f1d 100644 --- a/test/Client/AzureManaged.Tests/OnDemandSandboxActivitiesClientTests.cs +++ b/test/Client/AzureManaged.Tests/OnDemandSandboxActivitiesClientTests.cs @@ -140,7 +140,7 @@ public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_RequiresI } [Fact] - public void OnDemandSandboxActivityDeclarationResolver_ResolveDeclarations_UsesWorkerProfileConfigure() + public void OnDemandSandboxActivityDeclarationProvider_ResolveDeclarations_UsesWorkerProfileConfigure() { // Arrange using EnvironmentVariableScope image = new("DTS_ON_DEMAND_SANDBOX_ACTIVITY_IMAGE", "example.com/not-used:latest"); @@ -148,8 +148,10 @@ public void OnDemandSandboxActivityDeclarationResolver_ResolveDeclarations_UsesW using EnvironmentVariableScope memory = new("DTS_ON_DEMAND_SANDBOX_MEMORY", "4096Mi"); using EnvironmentVariableScope maxActivities = new("DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES", "99"); + OnDemandSandboxActivityDeclarationProvider provider = new(); + // Act - OnDemandSandboxOptions options = OnDemandSandboxActivityDeclarationResolver.ResolveDeclarations(TaskHub) + OnDemandSandboxOptions options = provider.ResolveDeclarations(TaskHub) .Single(options => options.WorkerProfileId == "annotated-profile"); OnDemandSandboxActivityDeclaration declaration = OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( options, @@ -170,10 +172,11 @@ public void OnDemandSandboxActivityDeclarationResolver_ResolveDeclarations_UsesW } [Fact] - public void OnDemandSandboxActivityDeclarationResolver_ValidateProfileType_RequiresProfileInterface() + public void OnDemandSandboxActivityDeclarationProvider_ValidateProfileType_RequiresProfileInterface() { + // Arrange // Act - Action action = () => OnDemandSandboxActivityDeclarationResolver.ValidateProfileType(typeof(ProfileWithoutInterface)); + Action action = () => OnDemandSandboxActivityDeclarationProvider.ValidateProfileType(typeof(ProfileWithoutInterface)); // Assert action.Should().Throw() @@ -210,7 +213,8 @@ public async Task EnableOnDemandSandboxActivitiesAsync_SendsWorkerProfileDeclara RecordingOnDemandSandboxLogCallInvoker callInvoker = new(); OnDemandSandboxActivitiesClient client = new( new OnDemandSandboxActivitiesGrpcTransport(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)), - "client-test-taskhub"); + "client-test-taskhub", + new OnDemandSandboxActivityDeclarationProvider()); // Act await client.EnableOnDemandSandboxActivitiesAsync(); From 6d9926211ad3893a5ef01e3ff48a834ab87ac9f0 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 11 Jun 2026 13:05:03 -0700 Subject: [PATCH 65/81] Enable work item filters in worker builder Call workerBuilder.UseWorkItemFilters() in the Durable Task worker setup (samples/on-demand-sandbox/main-app/Program.cs) to enable work item filtering during task processing. This allows configured filters to run for generated tasks. --- samples/on-demand-sandbox/main-app/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/samples/on-demand-sandbox/main-app/Program.cs b/samples/on-demand-sandbox/main-app/Program.cs index b3a2e9ed..e9fe97f4 100644 --- a/samples/on-demand-sandbox/main-app/Program.cs +++ b/samples/on-demand-sandbox/main-app/Program.cs @@ -29,6 +29,7 @@ builder.Services.AddDurableTaskWorker(workerBuilder => { workerBuilder.AddTasks(tasks => tasks.AddAllGeneratedTasks()); + workerBuilder.UseWorkItemFilters(); workerBuilder.UseDurableTaskScheduler(options => { options.EndpointAddress = endpoint; From 97f20205d8fda08b13262a3155c54f1844949a1c Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 11 Jun 2026 13:15:32 -0700 Subject: [PATCH 66/81] Validate sandbox worker registration metadata --- .../OnDemandSandboxWorkerMessageBuilder.cs | 9 ++- .../OnDemandSandboxActivitiesTests.cs | 71 +++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs b/src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs index 0a819004..e22922b5 100644 --- a/src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs +++ b/src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs @@ -24,7 +24,7 @@ public static Proto.OnDemandSandboxActivityWorkerMessage BuildWorkerStart( Check.NotNull(options); Check.NotNull(registeredActivityNames); - _ = OnDemandSandboxActivityMetadata.NormalizeRequired( + string taskHub = OnDemandSandboxActivityMetadata.NormalizeRequired( options.TaskHub, "On-demand sandbox activity worker registration requires a task hub name."); string[] activityNames = OnDemandSandboxActivityMetadata.ResolveActivityNames(registeredActivityNames); @@ -41,14 +41,17 @@ public static Proto.OnDemandSandboxActivityWorkerMessage BuildWorkerStart( string workerProfileId = OnDemandSandboxActivityMetadata.NormalizeWorkerProfileId( options.WorkerProfileId, "On-demand sandbox activity worker registration requires a worker profile ID."); + string dtsSandboxIdentifier = OnDemandSandboxActivityMetadata.NormalizeRequired( + Environment.GetEnvironmentVariable("DTS_SANDBOX_ID") ?? string.Empty, + "On-demand sandbox activity worker registration requires a DTS sandbox ID."); Proto.OnDemandSandboxActivityWorkerStart start = new() { - TaskHub = options.TaskHub, + TaskHub = taskHub, WorkerProfileId = workerProfileId, MaxActivitiesCount = options.MaxConcurrentActivities, Substrate = GetSubstrateFromEnvironment(), - DtsSandboxIdentifier = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID") ?? string.Empty, + DtsSandboxIdentifier = dtsSandboxIdentifier, }; start.ActivityNames.AddRange(activityNames); diff --git a/test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs b/test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs index 29dea0bc..cd23cc40 100644 --- a/test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs +++ b/test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs @@ -137,6 +137,72 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_SendsLi } } + [Fact] + public void OnDemandSandboxWorkerMessageBuilder_NormalizesTaskHubAndSandboxId() + { + // Arrange + string? originalSandboxId = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID"); + Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", " sandbox-1 "); + + try + { + OnDemandSandboxWorkerRuntimeOptions options = new() + { + TaskHub = " testhub ", + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + }; + + // Act + OnDemandSandboxActivityWorkerMessage message = OnDemandSandboxWorkerMessageBuilder.BuildWorkerStart( + options, + ["RemoteHello"]); + + // Assert + OnDemandSandboxActivityWorkerStart start = message.Start; + start.TaskHub.Should().Be(TaskHub); + start.DtsSandboxIdentifier.Should().Be("sandbox-1"); + } + finally + { + Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", originalSandboxId); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void OnDemandSandboxWorkerMessageBuilder_MissingSandboxId_Throws(string? sandboxId) + { + // Arrange + string? originalSandboxId = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID"); + Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", sandboxId); + + try + { + OnDemandSandboxWorkerRuntimeOptions options = new() + { + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + }; + + // Act + Action action = () => OnDemandSandboxWorkerMessageBuilder.BuildWorkerStart( + options, + ["RemoteHello"]); + + // Assert + action.Should().Throw() + .WithMessage("On-demand sandbox activity worker registration requires a DTS sandbox ID."); + } + finally + { + Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", originalSandboxId); + } + } + [Fact] public void OnDemandSandboxActivityTracker_TracksInFlightActivityCount() { @@ -168,6 +234,7 @@ public void OnDemandSandboxActivityTracker_TracksInFlightActivityCount() public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_SendsHeartbeatWithCurrentInFlightCount() { // Arrange + using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); OnDemandSandboxWorkerRuntimeOptions options = new() { TaskHub = TaskHub, @@ -204,6 +271,7 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_SendsHe public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_ReopensSessionAfterTransientStreamFailure() { // Arrange + using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); OnDemandSandboxWorkerRuntimeOptions options = new() { TaskHub = TaskHub, @@ -242,6 +310,7 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_Reopens public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_ReopensSessionAfterTerminalServerFailure() { // Arrange + using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); OnDemandSandboxWorkerRuntimeOptions options = new() { TaskHub = TaskHub, @@ -309,6 +378,7 @@ public void OnDemandSandboxActivityWorkerRegistrationHostedService_ComputeJitter public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_AppliesJitterToReconnectDelay() { // Arrange + using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); OnDemandSandboxWorkerRuntimeOptions options = new() { TaskHub = TaskHub, @@ -346,6 +416,7 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_Applies public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_StopAsync_DoesNotCompleteStreamWhileWriteIsInFlight() { // Arrange + using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); OnDemandSandboxWorkerRuntimeOptions options = new() { TaskHub = TaskHub, From fbc550c9a39332024419dc17fb48ee707ddce49a Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 11 Jun 2026 16:23:56 -0700 Subject: [PATCH 67/81] Address sandbox review feedback --- CHANGELOG.md | 4 +- Microsoft.DurableTask.sln | 10 +- README.md | 2 +- eng/publish/publish.yml | 62 +++++++++-- samples/on-demand-sandbox/main-app/Program.cs | 21 +++- .../main-app/WorkerProfiles.cs | 8 +- .../main-app/main-app.csproj | 4 +- .../remote-worker/remote-worker.csproj | 2 +- .../Client.AzureManaged.Sandboxes.csproj} | 7 +- .../ISandboxWorkerProfile.cs | 2 +- .../SandboxActivitiesClient.cs} | 28 ++--- ...itiesClientServiceCollectionExtensions.cs} | 20 ++-- .../SandboxActivityDeclarationBuilder.cs} | 14 +-- .../SandboxActivityDeclarationProvider.cs} | 26 ++--- .../SandboxWorkerProfileAttribute.cs} | 6 +- .../SandboxWorkerProfileOptions.cs} | 4 +- .../SandboxActivitiesGrpcTransport.cs} | 22 ++-- .../SandboxActivityMetadata.cs} | 2 +- ...leTaskSchedulerSandboxWorkerExtensions.cs} | 46 ++++---- .../Logs.cs | 2 +- .../SandboxActivityTracker.cs} | 4 +- ...ctivityWorkerRegistrationHostedService.cs} | 50 ++++----- .../SandboxWorkerMessageBuilder.cs} | 14 +-- .../SandboxWorkerRuntimeOptions.cs} | 6 +- .../Worker.AzureManaged.Sandboxes.csproj} | 7 +- .../Grpc/GrpcDurableTaskWorker.Processor.cs | 16 ++- src/Worker/Grpc/Logs.cs | 8 +- .../Client.AzureManaged.Tests.csproj | 2 +- ...sts.cs => SandboxActivitiesClientTests.cs} | 100 +++++++++--------- ...tiesTests.cs => SandboxActivitiesTests.cs} | 98 ++++++++--------- .../Worker.AzureManaged.Tests.csproj | 2 +- .../Grpc.Tests/GrpcDurableTaskWorkerTests.cs | 52 +++++++++ 32 files changed, 387 insertions(+), 264 deletions(-) rename src/Client/{AzureManaged.OnDemandSandbox/Client.AzureManaged.OnDemandSandbox.csproj => AzureManaged.Sandboxes/Client.AzureManaged.Sandboxes.csproj} (72%) rename src/Client/{AzureManaged.OnDemandSandbox => AzureManaged.Sandboxes}/ISandboxWorkerProfile.cs (88%) rename src/Client/{AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClient.cs => AzureManaged.Sandboxes/SandboxActivitiesClient.cs} (69%) rename src/Client/{AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs => AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs} (76%) rename src/Client/{AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs => AzureManaged.Sandboxes/SandboxActivityDeclarationBuilder.cs} (94%) rename src/Client/{AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationProvider.cs => AzureManaged.Sandboxes/SandboxActivityDeclarationProvider.cs} (80%) rename src/Client/{AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs => AzureManaged.Sandboxes/SandboxWorkerProfileAttribute.cs} (77%) rename src/Client/{AzureManaged.OnDemandSandbox/OnDemandSandboxOptions.cs => AzureManaged.Sandboxes/SandboxWorkerProfileOptions.cs} (96%) rename src/Shared/{AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs => AzureManaged.Sandboxes/SandboxActivitiesGrpcTransport.cs} (88%) rename src/Shared/{AzureManaged.OnDemandSandbox/OnDemandSandboxActivityMetadata.cs => AzureManaged.Sandboxes/SandboxActivityMetadata.cs} (97%) rename src/Worker/{AzureManaged.OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs => AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs} (77%) rename src/Worker/{AzureManaged.OnDemandSandbox => AzureManaged.Sandboxes}/Logs.cs (97%) rename src/Worker/{AzureManaged.OnDemandSandbox/OnDemandSandboxActivityTracker.cs => AzureManaged.Sandboxes/SandboxActivityTracker.cs} (90%) rename src/Worker/{AzureManaged.OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs => AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs} (88%) rename src/Worker/{AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs => AzureManaged.Sandboxes/SandboxWorkerMessageBuilder.cs} (87%) rename src/Worker/{AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs => AzureManaged.Sandboxes/SandboxWorkerRuntimeOptions.cs} (85%) rename src/Worker/{AzureManaged.OnDemandSandbox/Worker.AzureManaged.OnDemandSandbox.csproj => AzureManaged.Sandboxes/Worker.AzureManaged.Sandboxes.csproj} (75%) rename test/Client/AzureManaged.Tests/{OnDemandSandboxActivitiesClientTests.cs => SandboxActivitiesClientTests.cs} (73%) rename test/Worker/AzureManaged.Tests/{OnDemandSandboxActivitiesTests.cs => SandboxActivitiesTests.cs} (88%) diff --git a/CHANGELOG.md b/CHANGELOG.md index aab35404..dfa6e989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ ## Unreleased -- Split private preview on-demand sandbox APIs into opt-in `Microsoft.DurableTask.Client.AzureManaged.OnDemandSandbox` and `Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox` packages. -- Updated private preview on-demand sandbox worker profile declarations to use `OnDemandSandboxOptions.AddActivity(...)`, and updated the on-demand sandbox sample to share activity name constants between the main app and remote worker. +- Split private preview on-demand sandbox APIs into opt-in `Microsoft.DurableTask.Client.AzureManaged.Sandboxes` and `Microsoft.DurableTask.Worker.AzureManaged.Sandboxes` packages. +- Updated private preview on-demand sandbox worker profile declarations to use `SandboxWorkerProfileOptions.AddActivity(...)`, and updated the on-demand sandbox sample to share activity name constants between the main app and remote worker. - Added SDK-side validation for private preview on-demand sandbox CPU and memory resource quantities. diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index d49f3744..3c6d4539 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.3.32901.215 @@ -131,15 +131,15 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "main-app", "samples\on-dema EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "remote-worker", "samples\on-demand-sandbox\remote-worker\remote-worker.csproj", "{B7069604-DD97-4115-8B30-FC1D4C0E6D43}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged.OnDemandSandbox", "AzureManaged.OnDemandSandbox", "{28648169-70E4-D0BA-4357-338A556A7DA8}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged.Sandboxes", "AzureManaged.Sandboxes", "{28648169-70E4-D0BA-4357-338A556A7DA8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.AzureManaged.OnDemandSandbox", "src\Client\AzureManaged.OnDemandSandbox\Client.AzureManaged.OnDemandSandbox.csproj", "{A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.AzureManaged.Sandboxes", "src\Client\AzureManaged.Sandboxes\Client.AzureManaged.Sandboxes.csproj", "{A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{D4587EC0-1B16-8420-7502-A967139249D4}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged.OnDemandSandbox", "AzureManaged.OnDemandSandbox", "{9686B8F9-2644-6C9B-E567-55B0471E4584}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged.Sandboxes", "AzureManaged.Sandboxes", "{9686B8F9-2644-6C9B-E567-55B0471E4584}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Worker.AzureManaged.OnDemandSandbox", "src\Worker\AzureManaged.OnDemandSandbox\Worker.AzureManaged.OnDemandSandbox.csproj", "{C1995163-1DCE-405D-BE82-8B4B2584893E}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Worker.AzureManaged.Sandboxes", "src\Worker\AzureManaged.Sandboxes\Worker.AzureManaged.Sandboxes.csproj", "{C1995163-1DCE-405D-BE82-8B4B2584893E}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{53193780-CD18-2643-6953-C26F59EAEDF5}" EndProject diff --git a/README.md b/README.md index 3f7ecad0..2fc37547 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ This SDK can also be used with the Durable Task Scheduler directly, without any For runnable DTS emulator examples that demonstrate versioning, see the [WorkerVersioningSample](samples/WorkerVersioningSample/README.md) (deployment-based versioning), the [EternalOrchestrationVersionMigrationSample](samples/EternalOrchestrationVersionMigrationSample/README.md) (multi-version routing with `[DurableTask(Version = "...")]`), the [ActivityVersioningSample](samples/ActivityVersioningSample/README.md) (activity versioning with inherited defaults and explicit override support), and the [EntityWithVersionedOrchestrationSample](samples/EntityWithVersionedOrchestrationSample/README.md) (a single instance migrating v1→v2 via `ContinueAsNew(NewVersion)` while preserving entity-held state). -The [on-demand sandbox activities sample](samples/on-demand-sandbox/README.md) shows how to declare selected activities for DTS-managed on-demand sandbox execution and build the remote worker container image separately from the declarer app. +The [on-demand sandbox activities sample](samples/on-demand-sandbox/README.md) shows how to declare selected activities for Durable Task Scheduler (DTS)-managed on-demand sandbox execution and build the remote worker container image separately from the declarer app. ## Obtaining the Protobuf definitions diff --git a/eng/publish/publish.yml b/eng/publish/publish.yml index 561c85ec..a3009469 100644 --- a/eng/publish/publish.yml +++ b/eng/publish/publish.yml @@ -304,9 +304,9 @@ extends: packagesToPush: '$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Worker.Grpc.*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' # Despite this being a custom command, we need to keep this for 1ES validation packageParentPath: $(System.DefaultWorkingDirectory) # This needs to be set to some prefix of the `packagesToPush` parameter. Apparently it helps with SDL tooling - # NuGet release (Microsoft.DurableTask.Client.AzureManaged.*) + # NuGet release (Microsoft.DurableTask.Client.AzureManaged) - job: nugetRelease_Microsoft_DurableTask_Client_AzureManaged - displayName: NuGet Release (Microsoft.DurableTask.Client.AzureManaged.*) + displayName: NuGet Release (Microsoft.DurableTask.Client.AzureManaged) dependsOn: nugetApproval condition: succeeded('nugetApproval') # nuget packages need to be on ADO first templateContext: @@ -319,17 +319,63 @@ extends: targetPath: $(System.DefaultWorkingDirectory)/drop steps: - task: 1ES.PublishNuget@1 - displayName: 'NuGet push (Microsoft.DurableTask.Client.AzureManaged.*)' + displayName: 'NuGet push (Microsoft.DurableTask.Client.AzureManaged)' inputs: command: push nuGetFeedType: external publishFeedCredentials: 'DurableTask org NuGet API Key' - packagesToPush: '$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Client.AzureManaged.*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' # Despite this being a custom command, we need to keep this for 1ES validation + packagesToPush: '$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Client.AzureManaged.*.nupkg;!$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Client.AzureManaged.Sandboxes.*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' # Despite this being a custom command, we need to keep this for 1ES validation packageParentPath: $(System.DefaultWorkingDirectory) # This needs to be set to some prefix of the `packagesToPush` parameter. Apparently it helps with SDL tooling - # NuGet release (Microsoft.DurableTask.Worker.AzureManaged.*) + # NuGet release (Microsoft.DurableTask.Client.AzureManaged.Sandboxes) + - job: nugetRelease_Microsoft_DurableTask_Client_AzureManaged_Sandboxes + displayName: NuGet Release (Microsoft.DurableTask.Client.AzureManaged.Sandboxes) + dependsOn: nugetApproval + condition: succeeded('nugetApproval') # nuget packages need to be on ADO first + templateContext: + type: releaseJob + isProduction: true + inputs: + - input: pipelineArtifact + pipeline: officialPipeline # Pipeline reference as defined in the resources section + artifactName: drop + targetPath: $(System.DefaultWorkingDirectory)/drop + steps: + - task: 1ES.PublishNuget@1 + displayName: 'NuGet push (Microsoft.DurableTask.Client.AzureManaged.Sandboxes)' + inputs: + command: push + nuGetFeedType: external + publishFeedCredentials: 'DurableTask org NuGet API Key' + packagesToPush: '$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Client.AzureManaged.Sandboxes.*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' # Despite this being a custom command, we need to keep this for 1ES validation + packageParentPath: $(System.DefaultWorkingDirectory) # This needs to be set to some prefix of the `packagesToPush` parameter. Apparently it helps with SDL tooling + + # NuGet release (Microsoft.DurableTask.Worker.AzureManaged) - job: nugetRelease_Microsoft_DurableTask_Worker_AzureManaged - displayName: NuGet Release (Microsoft.DurableTask.Worker.AzureManaged.*) + displayName: NuGet Release (Microsoft.DurableTask.Worker.AzureManaged) + dependsOn: nugetApproval + condition: succeeded('nugetApproval') # nuget packages need to be on ADO first + templateContext: + type: releaseJob + isProduction: true + inputs: + - input: pipelineArtifact + pipeline: officialPipeline # Pipeline reference as defined in the resources section + artifactName: drop + targetPath: $(System.DefaultWorkingDirectory)/drop + steps: + - task: 1ES.PublishNuget@1 + displayName: 'NuGet push (Microsoft.DurableTask.Worker.AzureManaged)' + inputs: + command: push + nuGetFeedType: external + publishFeedCredentials: 'DurableTask org NuGet API Key' + packagesToPush: '$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Worker.AzureManaged.*.nupkg;!$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Worker.AzureManaged.Sandboxes.*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' # Despite this being a custom command, we need to keep this for 1ES validation + packageParentPath: $(System.DefaultWorkingDirectory) # This needs to be set to some prefix of the `packagesToPush` parameter. Apparently it helps with SDL tooling + + # NuGet release (Microsoft.DurableTask.Worker.AzureManaged.Sandboxes) + - job: nugetRelease_Microsoft_DurableTask_Worker_AzureManaged_Sandboxes + displayName: NuGet Release (Microsoft.DurableTask.Worker.AzureManaged.Sandboxes) dependsOn: nugetApproval condition: succeeded('nugetApproval') # nuget packages need to be on ADO first templateContext: @@ -342,12 +388,12 @@ extends: targetPath: $(System.DefaultWorkingDirectory)/drop steps: - task: 1ES.PublishNuget@1 - displayName: 'NuGet push (Microsoft.DurableTask.Worker.AzureManaged.*)' + displayName: 'NuGet push (Microsoft.DurableTask.Worker.AzureManaged.Sandboxes)' inputs: command: push nuGetFeedType: external publishFeedCredentials: 'DurableTask org NuGet API Key' - packagesToPush: '$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Worker.AzureManaged.*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' # Despite this being a custom command, we need to keep this for 1ES validation + packagesToPush: '$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Worker.AzureManaged.Sandboxes.*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' # Despite this being a custom command, we need to keep this for 1ES validation packageParentPath: $(System.DefaultWorkingDirectory) # This needs to be set to some prefix of the `packagesToPush` parameter. Apparently it helps with SDL tooling # NuGet release (Microsoft.DurableTask.ScheduledTasks) diff --git a/samples/on-demand-sandbox/main-app/Program.cs b/samples/on-demand-sandbox/main-app/Program.cs index e9fe97f4..1e9d92e2 100644 --- a/samples/on-demand-sandbox/main-app/Program.cs +++ b/samples/on-demand-sandbox/main-app/Program.cs @@ -16,8 +16,8 @@ const string Input = "on-demand-sandbox-sample"; HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); -string endpoint = builder.Configuration["OnDemandSandboxSample:EndpointAddress"]!; -string taskHub = builder.Configuration["OnDemandSandboxSample:TaskHubName"]!; +string endpoint = GetRequiredConfigurationValue("OnDemandSandboxSample:EndpointAddress"); +string taskHub = GetRequiredConfigurationValue("OnDemandSandboxSample:TaskHubName"); TokenCredential credential = new DefaultAzureCredential(); builder.Logging.AddSimpleConsole(options => { @@ -47,14 +47,14 @@ options.Credential = credential; }); }); -builder.Services.AddDurableTaskSchedulerOnDemandSandboxActivitiesClient(); +builder.Services.AddDurableTaskSchedulerSandboxActivitiesClient(); using IHost host = builder.Build(); await host.StartAsync(); -OnDemandSandboxActivitiesClient sandboxActivitiesClient = host.Services.GetRequiredService(); -await sandboxActivitiesClient.EnableOnDemandSandboxActivitiesAsync(); +SandboxActivitiesClient sandboxActivitiesClient = host.Services.GetRequiredService(); +await sandboxActivitiesClient.EnableSandboxActivitiesAsync(); DurableTaskClient client = host.Services.GetRequiredService(); string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( @@ -69,3 +69,14 @@ Console.WriteLine($"Output: {result.SerializedOutput ?? ""}"); await host.StopAsync(); + +string GetRequiredConfigurationValue(string key) +{ + string? value = builder.Configuration[key]; + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException($"Configuration value '{key}' must be set."); + } + + return value.Trim(); +} diff --git a/samples/on-demand-sandbox/main-app/WorkerProfiles.cs b/samples/on-demand-sandbox/main-app/WorkerProfiles.cs index 2c83df65..8352c02f 100644 --- a/samples/on-demand-sandbox/main-app/WorkerProfiles.cs +++ b/samples/on-demand-sandbox/main-app/WorkerProfiles.cs @@ -6,10 +6,10 @@ namespace Microsoft.DurableTask.Samples.OnDemandSandbox.MainApp; -[OnDemandSandboxWorkerProfile("default")] +[SandboxWorkerProfile("default")] internal sealed class DefaultSandboxWorkerProfile : ISandboxWorkerProfile { - public void Configure(OnDemandSandboxOptions options) + public void Configure(SandboxWorkerProfileOptions options) { options.ContainerImage = Environment.GetEnvironmentVariable("DTS_ON_DEMAND_SANDBOX_CONTAINER_IMAGE") ?? "on-demand-sandbox-remote-worker:local"; options.ImagePullManagedIdentityClientId = GetRequiredEnvironmentVariable("DTS_ON_DEMAND_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID"); @@ -22,7 +22,7 @@ public void Configure(OnDemandSandboxOptions options) options.AddActivity(ActivityNames.RemoteHello); } - static void AddEnvironmentVariable(OnDemandSandboxOptions options, string name) + static void AddEnvironmentVariable(SandboxWorkerProfileOptions options, string name) { string? value = Environment.GetEnvironmentVariable(name); if (!string.IsNullOrWhiteSpace(value)) @@ -39,6 +39,6 @@ static string GetRequiredEnvironmentVariable(string name) throw new InvalidOperationException($"{name} must be set."); } - return value; + return value.Trim(); } } diff --git a/samples/on-demand-sandbox/main-app/main-app.csproj b/samples/on-demand-sandbox/main-app/main-app.csproj index 88ce55e1..86df35d7 100644 --- a/samples/on-demand-sandbox/main-app/main-app.csproj +++ b/samples/on-demand-sandbox/main-app/main-app.csproj @@ -15,9 +15,9 @@ - + - + diff --git a/samples/on-demand-sandbox/remote-worker/remote-worker.csproj b/samples/on-demand-sandbox/remote-worker/remote-worker.csproj index b39f4767..9a9a6aca 100644 --- a/samples/on-demand-sandbox/remote-worker/remote-worker.csproj +++ b/samples/on-demand-sandbox/remote-worker/remote-worker.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Client/AzureManaged.OnDemandSandbox/Client.AzureManaged.OnDemandSandbox.csproj b/src/Client/AzureManaged.Sandboxes/Client.AzureManaged.Sandboxes.csproj similarity index 72% rename from src/Client/AzureManaged.OnDemandSandbox/Client.AzureManaged.OnDemandSandbox.csproj rename to src/Client/AzureManaged.Sandboxes/Client.AzureManaged.Sandboxes.csproj index 42c1fb11..d25d0412 100644 --- a/src/Client/AzureManaged.OnDemandSandbox/Client.AzureManaged.OnDemandSandbox.csproj +++ b/src/Client/AzureManaged.Sandboxes/Client.AzureManaged.Sandboxes.csproj @@ -1,10 +1,9 @@ - net6.0;net8.0;net10.0 + net10.0 Azure Managed on-demand sandbox activity management extensions for the Durable Task Framework client. - Microsoft.DurableTask.Client.AzureManaged.OnDemandSandbox - Microsoft.DurableTask + Microsoft.DurableTask.Client.AzureManaged.Sandboxes true @@ -18,7 +17,7 @@ - + diff --git a/src/Client/AzureManaged.OnDemandSandbox/ISandboxWorkerProfile.cs b/src/Client/AzureManaged.Sandboxes/ISandboxWorkerProfile.cs similarity index 88% rename from src/Client/AzureManaged.OnDemandSandbox/ISandboxWorkerProfile.cs rename to src/Client/AzureManaged.Sandboxes/ISandboxWorkerProfile.cs index f6b9bc82..10e88a00 100644 --- a/src/Client/AzureManaged.OnDemandSandbox/ISandboxWorkerProfile.cs +++ b/src/Client/AzureManaged.Sandboxes/ISandboxWorkerProfile.cs @@ -12,5 +12,5 @@ public interface ISandboxWorkerProfile /// Configures the on-demand sandbox worker profile declaration options. /// /// The declaration options to configure. - void Configure(OnDemandSandboxOptions options); + void Configure(SandboxWorkerProfileOptions options); } diff --git a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClient.cs b/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClient.cs similarity index 69% rename from src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClient.cs rename to src/Client/AzureManaged.Sandboxes/SandboxActivitiesClient.cs index b58f6bf2..9291f538 100644 --- a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClient.cs +++ b/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClient.cs @@ -9,22 +9,22 @@ namespace Microsoft.DurableTask.Client.AzureManaged; /// /// Client for DTS on-demand sandbox activity management operations. /// -public sealed class OnDemandSandboxActivitiesClient +public sealed class SandboxActivitiesClient { - readonly IOnDemandSandboxActivitiesTransport transport; - readonly OnDemandSandboxActivityDeclarationProvider declarationProvider; + readonly ISandboxActivitiesTransport transport; + readonly SandboxActivityDeclarationProvider declarationProvider; readonly string taskHub; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The transport used to call DTS on-demand sandbox management operations. /// The task hub whose declarations should be sent to DTS. /// The declaration provider. - internal OnDemandSandboxActivitiesClient( - IOnDemandSandboxActivitiesTransport transport, + internal SandboxActivitiesClient( + ISandboxActivitiesTransport transport, string taskHub, - OnDemandSandboxActivityDeclarationProvider declarationProvider) + SandboxActivityDeclarationProvider declarationProvider) { this.transport = Check.NotNull(transport); this.declarationProvider = Check.NotNull(declarationProvider); @@ -38,19 +38,19 @@ internal OnDemandSandboxActivitiesClient( /// /// The cancellation token used to cancel the request. /// A task that completes when DTS accepts all declarations. - public async Task EnableOnDemandSandboxActivitiesAsync(CancellationToken cancellation = default) + public async Task EnableSandboxActivitiesAsync(CancellationToken cancellation = default) { - IReadOnlyList declarations = this.declarationProvider.ResolveDeclarations(this.taskHub); - foreach (OnDemandSandboxOptions options in declarations) + IReadOnlyList declarations = this.declarationProvider.ResolveDeclarations(this.taskHub); + foreach (SandboxWorkerProfileOptions options in declarations) { - string[] activityNames = OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames); + string[] activityNames = SandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames); if (activityNames.Length == 0) { continue; } Proto.OnDemandSandboxActivityDeclaration declaration = - OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration(options, activityNames); + SandboxActivityDeclarationBuilder.BuildDeclaration(options, activityNames); await this.transport.DeclareOnDemandSandboxActivitiesAsync( declaration, this.taskHub, @@ -65,7 +65,7 @@ await this.transport.DeclareOnDemandSandboxActivitiesAsync( /// The worker profile ID whose declaration should be removed. /// The cancellation token used to cancel the request. /// A task that completes when DTS removes the declaration. - public Task RemoveOnDemandSandboxActivityDeclarationAsync( + public Task RemoveSandboxActivityDeclarationAsync( string workerProfileId, CancellationToken cancellation = default) { @@ -73,7 +73,7 @@ public Task RemoveOnDemandSandboxActivityDeclarationAsync( ? throw new ArgumentException("Worker profile ID is required.", nameof(workerProfileId)) : workerProfileId.Trim(); - return this.transport.RemoveOnDemandSandboxActivityDeclarationAsync( + return this.transport.RemoveSandboxActivityDeclarationAsync( normalizedWorkerProfileId, this.taskHub, cancellation); diff --git a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs b/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs similarity index 76% rename from src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs rename to src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs index c44d9c3c..765cbbd6 100644 --- a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesClientServiceCollectionExtensions.cs +++ b/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs @@ -14,15 +14,15 @@ namespace Microsoft.DurableTask.Client.AzureManaged; /// /// Extension methods for registering DTS on-demand sandbox activity management clients. /// -public static class OnDemandSandboxActivitiesClientServiceCollectionExtensions +public static class SandboxActivitiesClientServiceCollectionExtensions { /// /// Adds a DTS on-demand sandbox activity management client using the default Durable Task client configuration. /// /// The service collection to configure. /// The original service collection, for call chaining. - public static IServiceCollection AddDurableTaskSchedulerOnDemandSandboxActivitiesClient(this IServiceCollection services) - => AddDurableTaskSchedulerOnDemandSandboxActivitiesClient(services, Options.DefaultName); + public static IServiceCollection AddDurableTaskSchedulerSandboxActivitiesClient(this IServiceCollection services) + => AddDurableTaskSchedulerSandboxActivitiesClient(services, Options.DefaultName); /// /// Adds a DTS on-demand sandbox activity management client using a named Durable Task client configuration. @@ -30,14 +30,14 @@ public static IServiceCollection AddDurableTaskSchedulerOnDemandSandboxActivitie /// The service collection to configure. /// The Durable Task client name whose scheduler channel should be reused. /// The original service collection, for call chaining. - public static IServiceCollection AddDurableTaskSchedulerOnDemandSandboxActivitiesClient( + public static IServiceCollection AddDurableTaskSchedulerSandboxActivitiesClient( this IServiceCollection services, string clientName) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(clientName); - services.TryAddSingleton(); + services.TryAddSingleton(); services.AddSingleton(provider => { @@ -47,12 +47,12 @@ public static IServiceCollection AddDurableTaskSchedulerOnDemandSandboxActivitie GrpcDurableTaskClientOptions options = provider .GetRequiredService>() .Get(clientName); - OnDemandSandboxActivityDeclarationProvider declarationProvider = provider.GetRequiredService(); + SandboxActivityDeclarationProvider declarationProvider = provider.GetRequiredService(); if (options.CallInvoker is { } callInvoker) { - return new OnDemandSandboxActivitiesClient( - new OnDemandSandboxActivitiesGrpcTransport( + return new SandboxActivitiesClient( + new SandboxActivitiesGrpcTransport( new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker), attachTaskHubMetadata: false), schedulerOptions.TaskHubName, @@ -61,8 +61,8 @@ public static IServiceCollection AddDurableTaskSchedulerOnDemandSandboxActivitie if (options.Channel is GrpcChannel channel) { - return new OnDemandSandboxActivitiesClient( - new OnDemandSandboxActivitiesGrpcTransport( + return new SandboxActivitiesClient( + new SandboxActivitiesGrpcTransport( new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(channel.CreateCallInvoker()), attachTaskHubMetadata: false), schedulerOptions.TaskHubName, diff --git a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs b/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationBuilder.cs similarity index 94% rename from src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs rename to src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationBuilder.cs index 2330ccd3..f0e4dba6 100644 --- a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationBuilder.cs +++ b/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationBuilder.cs @@ -10,7 +10,7 @@ namespace Microsoft.DurableTask.Client.AzureManaged; /// /// Builds and normalizes on-demand sandbox activity declaration protocol messages. /// -static class OnDemandSandboxActivityDeclarationBuilder +static class SandboxActivityDeclarationBuilder { /// /// Resolves configured activity names for on-demand sandbox activity execution. @@ -19,7 +19,7 @@ static class OnDemandSandboxActivityDeclarationBuilder /// The normalized activity names. public static string[] ResolveActivityNames(IEnumerable configuredNames) { - return OnDemandSandboxActivityMetadata.ResolveActivityNames(configuredNames); + return SandboxActivityMetadata.ResolveActivityNames(configuredNames); } /// @@ -29,7 +29,7 @@ public static string[] ResolveActivityNames(IEnumerable configuredNames) /// The activity names included in the declaration. /// The declaration protocol message. public static Proto.OnDemandSandboxActivityDeclaration BuildDeclaration( - OnDemandSandboxOptions options, + SandboxWorkerProfileOptions options, IReadOnlyCollection activityNames) { Check.NotNull(options); @@ -77,7 +77,7 @@ public static Proto.OnDemandSandboxActivityDeclaration BuildDeclaration( /// The normalized worker profile ID. internal static string NormalizeWorkerProfileId(string value, string errorMessage) { - return OnDemandSandboxActivityMetadata.NormalizeRequired(value, errorMessage); + return SandboxActivityMetadata.NormalizeRequired(value, errorMessage); } /// @@ -88,10 +88,10 @@ internal static string NormalizeWorkerProfileId(string value, string errorMessag /// The normalized value. internal static string NormalizeRequired(string value, string errorMessage) { - return OnDemandSandboxActivityMetadata.NormalizeRequired(value, errorMessage); + return SandboxActivityMetadata.NormalizeRequired(value, errorMessage); } - static Proto.OnDemandSandboxActivityImage BuildImage(OnDemandSandboxOptions options) + static Proto.OnDemandSandboxActivityImage BuildImage(SandboxWorkerProfileOptions options) { string imageRef = NormalizeRequired( options.ContainerImage ?? string.Empty, @@ -108,7 +108,7 @@ static Proto.OnDemandSandboxActivityImage BuildImage(OnDemandSandboxOptions opti return image; } - static Proto.OnDemandSandboxActivityResources BuildResources(OnDemandSandboxOptions options) + static Proto.OnDemandSandboxActivityResources BuildResources(SandboxWorkerProfileOptions options) { string cpu = NormalizeCpu(options.Cpu); string memory = NormalizeMemory(options.Memory); diff --git a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationProvider.cs b/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationProvider.cs similarity index 80% rename from src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationProvider.cs rename to src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationProvider.cs index e5e4fc94..97decf55 100644 --- a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityDeclarationProvider.cs +++ b/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationProvider.cs @@ -9,14 +9,14 @@ namespace Microsoft.DurableTask.Client.AzureManaged; /// /// Provides on-demand sandbox activity declarations from worker profile configuration. /// -sealed class OnDemandSandboxActivityDeclarationProvider +sealed class SandboxActivityDeclarationProvider { readonly Lazy profiles; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public OnDemandSandboxActivityDeclarationProvider() + public SandboxActivityDeclarationProvider() { this.profiles = new Lazy(ScanProfiles, LazyThreadSafetyMode.ExecutionAndPublication); } @@ -26,15 +26,15 @@ public OnDemandSandboxActivityDeclarationProvider() /// /// The task hub name. /// The resolved on-demand sandbox declaration options. - public IReadOnlyList ResolveDeclarations(string taskHub) + public IReadOnlyList ResolveDeclarations(string taskHub) { string normalizedTaskHub = string.IsNullOrWhiteSpace(taskHub) ? throw new InvalidOperationException("On-demand sandbox activity declaration requires a task hub name.") : taskHub.Trim(); - OnDemandSandboxOptions[] declarations = this.profiles.Value + SandboxWorkerProfileOptions[] declarations = this.profiles.Value .Select(profile => CreateOptions(normalizedTaskHub, profile)) - .Where(static options => OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames).Length > 0) + .Where(static options => SandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames).Length > 0) .ToArray(); ValidateActivityOwnership(declarations); @@ -59,7 +59,7 @@ static ProfileMetadata[] ScanProfiles() Dictionary profiles = new(StringComparer.Ordinal); foreach (Type type in GetCandidateTypes()) { - if (type.GetCustomAttribute() is not { } profile) + if (type.GetCustomAttribute() is not { } profile) { continue; } @@ -77,11 +77,11 @@ static ProfileMetadata[] ScanProfiles() return profiles.Values.ToArray(); } - static OnDemandSandboxOptions CreateOptions( + static SandboxWorkerProfileOptions CreateOptions( string taskHub, ProfileMetadata profile) { - OnDemandSandboxOptions options = new() + SandboxWorkerProfileOptions options = new() { TaskHub = taskHub, WorkerProfileId = profile.WorkerProfileId, @@ -91,7 +91,7 @@ static OnDemandSandboxOptions CreateOptions( return options; } - static void ConfigureProfile(Type profileType, OnDemandSandboxOptions options) + static void ConfigureProfile(Type profileType, SandboxWorkerProfileOptions options) { ValidateProfileType(profileType); @@ -126,12 +126,12 @@ static IEnumerable GetCandidateTypes() } } - static void ValidateActivityOwnership(IEnumerable declarations) + static void ValidateActivityOwnership(IEnumerable declarations) { Dictionary activityOwners = new(StringComparer.Ordinal); - foreach (OnDemandSandboxOptions declaration in declarations) + foreach (SandboxWorkerProfileOptions declaration in declarations) { - foreach (string activityName in OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(declaration.ActivityNames)) + foreach (string activityName in SandboxActivityDeclarationBuilder.ResolveActivityNames(declaration.ActivityNames)) { if (activityOwners.TryGetValue(activityName, out string? existingProfile) && !string.Equals(existingProfile, declaration.WorkerProfileId, StringComparison.Ordinal)) diff --git a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs b/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileAttribute.cs similarity index 77% rename from src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs rename to src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileAttribute.cs index cdd78759..30844afa 100644 --- a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerProfileAttribute.cs +++ b/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileAttribute.cs @@ -7,13 +7,13 @@ namespace Microsoft.DurableTask.Client.AzureManaged; /// Declares an on-demand sandbox worker profile that DTS can start for activities declared by the profile. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] -public sealed class OnDemandSandboxWorkerProfileAttribute : Attribute +public sealed class SandboxWorkerProfileAttribute : Attribute { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The worker profile ID. - public OnDemandSandboxWorkerProfileAttribute(string workerProfileId) + public SandboxWorkerProfileAttribute(string workerProfileId) { this.WorkerProfileId = string.IsNullOrWhiteSpace(workerProfileId) ? throw new ArgumentException("On-demand sandbox worker profile ID cannot be empty.", nameof(workerProfileId)) diff --git a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxOptions.cs b/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileOptions.cs similarity index 96% rename from src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxOptions.cs rename to src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileOptions.cs index e81fa0ee..6bd00ba8 100644 --- a/src/Client/AzureManaged.OnDemandSandbox/OnDemandSandboxOptions.cs +++ b/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileOptions.cs @@ -10,12 +10,12 @@ namespace Microsoft.DurableTask.Client.AzureManaged; /// /// Options for declaring on-demand sandbox activities and the worker image DTS should start for them. /// -public sealed class OnDemandSandboxOptions +public sealed class SandboxWorkerProfileOptions { /// /// Default worker profile ID used when no profile is specified. /// - internal const string DefaultWorkerProfileId = OnDemandSandboxActivityMetadata.DefaultWorkerProfileId; + internal const string DefaultWorkerProfileId = SandboxActivityMetadata.DefaultWorkerProfileId; /// /// Gets or sets the task hub where the on-demand sandbox activity declaration is stored. diff --git a/src/Shared/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs b/src/Shared/AzureManaged.Sandboxes/SandboxActivitiesGrpcTransport.cs similarity index 88% rename from src/Shared/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs rename to src/Shared/AzureManaged.Sandboxes/SandboxActivitiesGrpcTransport.cs index bd96ea7e..d75d984f 100644 --- a/src/Shared/AzureManaged.OnDemandSandbox/OnDemandSandboxActivitiesGrpcTransport.cs +++ b/src/Shared/AzureManaged.Sandboxes/SandboxActivitiesGrpcTransport.cs @@ -9,7 +9,7 @@ namespace Microsoft.DurableTask.AzureManaged.Internal; /// /// Transport abstraction for the on-demand sandbox activities gRPC service. /// -interface IOnDemandSandboxActivitiesTransport +interface ISandboxActivitiesTransport { /// /// Declares on-demand sandbox activities to DTS. @@ -30,7 +30,7 @@ interface IOnDemandSandboxActivitiesTransport /// The task hub that owns the declaration. /// The cancellation token. /// The removal result. - Task RemoveOnDemandSandboxActivityDeclarationAsync( + Task RemoveSandboxActivityDeclarationAsync( string workerProfileId, string taskHub, CancellationToken cancellationToken); @@ -41,13 +41,13 @@ interface IOnDemandSandboxActivitiesTransport /// The task hub that owns the worker session. /// The cancellation token. /// The worker registration session. - IOnDemandSandboxActivityWorkerSession OpenOnDemandSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken); + ISandboxActivityWorkerSession OpenOnDemandSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken); } /// /// Client-streaming session used by an on-demand sandbox activity worker registration. /// -interface IOnDemandSandboxActivityWorkerSession : IAsyncDisposable +interface ISandboxActivityWorkerSession : IAsyncDisposable { /// /// Writes a worker registration message to the stream. @@ -70,19 +70,19 @@ interface IOnDemandSandboxActivityWorkerSession : IAsyncDisposable } /// -/// gRPC-backed implementation of . +/// gRPC-backed implementation of . /// -sealed class OnDemandSandboxActivitiesGrpcTransport : IOnDemandSandboxActivitiesTransport +sealed class SandboxActivitiesGrpcTransport : ISandboxActivitiesTransport { readonly Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client; readonly bool attachTaskHubMetadata; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The generated on-demand sandbox activities gRPC client. /// True to add per-call task hub metadata when the underlying channel does not already do so. - public OnDemandSandboxActivitiesGrpcTransport( + public SandboxActivitiesGrpcTransport( Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client, bool attachTaskHubMetadata = true) { @@ -105,7 +105,7 @@ public OnDemandSandboxActivitiesGrpcTransport( } /// - public async Task RemoveOnDemandSandboxActivityDeclarationAsync( + public async Task RemoveSandboxActivityDeclarationAsync( string workerProfileId, string taskHub, CancellationToken cancellationToken) @@ -124,7 +124,7 @@ public OnDemandSandboxActivitiesGrpcTransport( } /// - public IOnDemandSandboxActivityWorkerSession OpenOnDemandSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken) + public ISandboxActivityWorkerSession OpenOnDemandSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken) { AsyncClientStreamingCall call = this.client.ConnectOnDemandSandboxActivityWorker( @@ -140,7 +140,7 @@ public IOnDemandSandboxActivityWorkerSession OpenOnDemandSandboxActivityWorkerSe /// /// gRPC-backed on-demand sandbox activity worker registration session. /// - sealed class GrpcOnDemandSandboxActivityWorkerSession : IOnDemandSandboxActivityWorkerSession + sealed class GrpcOnDemandSandboxActivityWorkerSession : ISandboxActivityWorkerSession { readonly AsyncClientStreamingCall call; diff --git a/src/Shared/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityMetadata.cs b/src/Shared/AzureManaged.Sandboxes/SandboxActivityMetadata.cs similarity index 97% rename from src/Shared/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityMetadata.cs rename to src/Shared/AzureManaged.Sandboxes/SandboxActivityMetadata.cs index 076cf5be..2dee0026 100644 --- a/src/Shared/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityMetadata.cs +++ b/src/Shared/AzureManaged.Sandboxes/SandboxActivityMetadata.cs @@ -6,7 +6,7 @@ namespace Microsoft.DurableTask.AzureManaged.Internal; /// /// Shared normalization helpers for on-demand sandbox activity metadata. /// -static class OnDemandSandboxActivityMetadata +static class SandboxActivityMetadata { /// /// Default worker profile ID used when no profile is specified. diff --git a/src/Worker/AzureManaged.OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs similarity index 77% rename from src/Worker/AzureManaged.OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs rename to src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs index 6f1170b4..2d43b978 100644 --- a/src/Worker/AzureManaged.OnDemandSandbox/DurableTaskSchedulerOnDemandSandboxWorkerExtensions.cs +++ b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs @@ -8,7 +8,7 @@ using Grpc.Net.Client; using Microsoft.DurableTask.AzureManaged.Internal; using Microsoft.DurableTask.Protobuf.OnDemandSandbox; -using Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; +using Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; using Microsoft.DurableTask.Worker.Grpc; using Microsoft.DurableTask.Worker.Grpc.Internal; using Microsoft.Extensions.DependencyInjection; @@ -21,7 +21,7 @@ namespace Microsoft.DurableTask.Worker.AzureManaged; /// /// Extension methods for configuring Azure Managed Durable Task workers with on-demand sandbox activity support. /// -public static class DurableTaskSchedulerOnDemandSandboxWorkerExtensions +public static class DurableTaskSchedulerSandboxWorkerExtensions { const string UseSandboxWorkerNoActivitiesErrorMessage = "On-demand sandbox workers require at least one registered activity. " + @@ -41,7 +41,7 @@ public static IDurableTaskWorkerBuilder UseSandboxWorker(this IDurableTaskWorker ConfigureDurableTaskSchedulerFromEnvironment(builder); builder.UseWorkItemFilters(); - builder.Services.AddOptions(builder.Name) + builder.Services.AddOptions(builder.Name) .PostConfigure>((options, schedulerOptions) => { ApplyRuntimeTaskHubDefault(options, schedulerOptions.Get(builder.Name).TaskHubName); @@ -49,15 +49,15 @@ public static IDurableTaskWorkerBuilder UseSandboxWorker(this IDurableTaskWorker }); builder.Services.AddOptions(builder.Name) - .PostConfigure>((options, runtimeOptions) => - ConfigureOnDemandSandboxWorkerConcurrency(options, runtimeOptions.Get(builder.Name))); + .PostConfigure>((options, runtimeOptions) => + ConfigureSandboxWorkerConcurrency(options, runtimeOptions.Get(builder.Name))); builder.Services.AddOptions(builder.Name) .PostConfigure(IncludeOnlyRegisteredActivities); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddOptions(builder.Name) - .Configure((options, activityTracker) => + .Configure((options, activityTracker) => options.ConfigureActivityNotification(phase => { if (phase == ActivityNotificationPhase.Started) @@ -70,7 +70,7 @@ public static IDurableTaskWorkerBuilder UseSandboxWorker(this IDurableTaskWorker } })); - builder.Services.AddSingleton(sp => CreateOnDemandSandboxActivityWorkerRegistrationHostedService(sp, builder.Name)); + builder.Services.AddSingleton(sp => CreateSandboxActivityWorkerRegistrationHostedService(sp, builder.Name)); return builder; } @@ -85,45 +85,45 @@ static void IncludeOnlyRegisteredActivities(DurableTaskWorkerWorkItemFilters fil filters.Entities = []; } - static void ConfigureOnDemandSandboxWorkerConcurrency( + static void ConfigureSandboxWorkerConcurrency( DurableTaskWorkerOptions options, - OnDemandSandboxWorkerRuntimeOptions runtimeOptions) + SandboxWorkerRuntimeOptions runtimeOptions) { options.Concurrency.MaximumConcurrentActivityWorkItems = runtimeOptions.MaxConcurrentActivities; options.Concurrency.MaximumConcurrentOrchestrationWorkItems = 0; options.Concurrency.MaximumConcurrentEntityWorkItems = 0; } - static OnDemandSandboxActivityWorkerRegistrationHostedService CreateOnDemandSandboxActivityWorkerRegistrationHostedService( + static SandboxActivityWorkerRegistrationHostedService CreateSandboxActivityWorkerRegistrationHostedService( IServiceProvider services, string builderName) { - OnDemandSandboxWorkerRuntimeOptions options = services.GetRequiredService>().Get(builderName); + SandboxWorkerRuntimeOptions options = services.GetRequiredService>().Get(builderName); ILoggerFactory loggerFactory = services.GetRequiredService(); IHostApplicationLifetime? lifetime = services.GetService(); - OnDemandSandboxActivityTracker activityTracker = services.GetRequiredService(); + SandboxActivityTracker activityTracker = services.GetRequiredService(); DurableTaskWorkerWorkItemFilters filters = services.GetRequiredService>().Get(builderName); - return new OnDemandSandboxActivityWorkerRegistrationHostedService( - CreateOnDemandSandboxActivitiesTransport(services, builderName), + return new SandboxActivityWorkerRegistrationHostedService( + CreateSandboxActivitiesTransport(services, builderName), options, ResolveActivityFilterNames(filters.Activities), - loggerFactory.CreateLogger(), + loggerFactory.CreateLogger(), lifetime, activityTracker); } - static OnDemandSandboxActivitiesGrpcTransport CreateOnDemandSandboxActivitiesTransport(IServiceProvider services, string builderName) + static SandboxActivitiesGrpcTransport CreateSandboxActivitiesTransport(IServiceProvider services, string builderName) { GrpcDurableTaskWorkerOptions options = services.GetRequiredService>().Get(builderName); if (options.CallInvoker is { } callInvoker) { - return new OnDemandSandboxActivitiesGrpcTransport(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)); + return new SandboxActivitiesGrpcTransport(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)); } if (options.Channel is { } channel) { - return new OnDemandSandboxActivitiesGrpcTransport( + return new SandboxActivitiesGrpcTransport( new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(channel.CreateCallInvoker()), attachTaskHubMetadata: false); } @@ -131,7 +131,7 @@ static OnDemandSandboxActivitiesGrpcTransport CreateOnDemandSandboxActivitiesTra throw new InvalidOperationException("Azure Managed on-demand sandbox activities require a configured gRPC channel or call invoker."); } - static void ApplyRuntimeTaskHubDefault(OnDemandSandboxWorkerRuntimeOptions options, string taskHubName) + static void ApplyRuntimeTaskHubDefault(SandboxWorkerRuntimeOptions options, string taskHubName) { if (string.IsNullOrWhiteSpace(options.TaskHub) && !string.IsNullOrWhiteSpace(taskHubName)) { @@ -171,9 +171,9 @@ static string GetRequiredEnvironmentVariable(string name) : value.Trim(); } - static void ApplyWorkerEnvironmentOverrides(OnDemandSandboxWorkerRuntimeOptions options) + static void ApplyWorkerEnvironmentOverrides(SandboxWorkerRuntimeOptions options) { - ValidateOnDemandSandboxWorkerSubstrate(GetRequiredEnvironmentVariable("DTS_SUBSTRATE")); + ValidateSandboxWorkerSubstrate(GetRequiredEnvironmentVariable("DTS_SUBSTRATE")); string? workerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID"); if (!string.IsNullOrWhiteSpace(workerProfileId)) @@ -187,7 +187,7 @@ static void ApplyWorkerEnvironmentOverrides(OnDemandSandboxWorkerRuntimeOptions } } - static void ValidateOnDemandSandboxWorkerSubstrate(string substrate) + static void ValidateSandboxWorkerSubstrate(string substrate) { if (!string.Equals(substrate, "Sandbox", StringComparison.OrdinalIgnoreCase) && !string.Equals(substrate, "AcaSessionPool", StringComparison.OrdinalIgnoreCase)) diff --git a/src/Worker/AzureManaged.OnDemandSandbox/Logs.cs b/src/Worker/AzureManaged.Sandboxes/Logs.cs similarity index 97% rename from src/Worker/AzureManaged.OnDemandSandbox/Logs.cs rename to src/Worker/AzureManaged.Sandboxes/Logs.cs index 0a78e5d0..ca0204a1 100644 --- a/src/Worker/AzureManaged.OnDemandSandbox/Logs.cs +++ b/src/Worker/AzureManaged.Sandboxes/Logs.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Logging; using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; -namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; +namespace Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; /// /// Log messages for on-demand sandbox activity services. diff --git a/src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityTracker.cs b/src/Worker/AzureManaged.Sandboxes/SandboxActivityTracker.cs similarity index 90% rename from src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityTracker.cs rename to src/Worker/AzureManaged.Sandboxes/SandboxActivityTracker.cs index c45a3c6c..610a45fa 100644 --- a/src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityTracker.cs +++ b/src/Worker/AzureManaged.Sandboxes/SandboxActivityTracker.cs @@ -1,12 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; +namespace Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; /// /// Tracks activity execution state for an on-demand sandbox worker process. /// -sealed class OnDemandSandboxActivityTracker +sealed class SandboxActivityTracker { int activeActivityCount; diff --git a/src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs b/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs similarity index 88% rename from src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs rename to src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs index 557b4b14..d1cdf735 100644 --- a/src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxActivityWorkerRegistrationHostedService.cs +++ b/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs @@ -8,28 +8,28 @@ using Microsoft.Extensions.Logging; using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; -namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; +namespace Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; /// /// Hosted service that registers a running process as an on-demand sandbox activity worker with DTS. /// -sealed class OnDemandSandboxActivityWorkerRegistrationHostedService : IHostedService, IAsyncDisposable +sealed class SandboxActivityWorkerRegistrationHostedService : IHostedService, IAsyncDisposable { readonly object sync = new(); - readonly IOnDemandSandboxActivitiesTransport transport; - readonly OnDemandSandboxWorkerRuntimeOptions options; + readonly ISandboxActivitiesTransport transport; + readonly SandboxWorkerRuntimeOptions options; readonly IReadOnlyCollection registeredActivityNames; - readonly ILogger logger; + readonly ILogger logger; readonly IHostApplicationLifetime? lifetime; - readonly OnDemandSandboxActivityTracker? activityTracker; + readonly SandboxActivityTracker? activityTracker; readonly Random reconnectJitter; readonly SemaphoreSlim streamSync = new(1, 1); CancellationTokenSource? cts; - IOnDemandSandboxActivityWorkerSession? session; + ISandboxActivityWorkerSession? session; Task? pump; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The on-demand sandbox activities transport. /// The on-demand sandbox worker runtime options. @@ -38,13 +38,13 @@ sealed class OnDemandSandboxActivityWorkerRegistrationHostedService : IHostedSer /// The optional application lifetime used to stop the host when a non-retriable registration stream failure occurs. /// The optional activity tracker used to report live in-flight activity count. /// The optional random source used to jitter reconnect delays. - public OnDemandSandboxActivityWorkerRegistrationHostedService( - IOnDemandSandboxActivitiesTransport transport, - OnDemandSandboxWorkerRuntimeOptions options, + public SandboxActivityWorkerRegistrationHostedService( + ISandboxActivitiesTransport transport, + SandboxWorkerRuntimeOptions options, IReadOnlyCollection registeredActivityNames, - ILogger logger, + ILogger logger, IHostApplicationLifetime? lifetime = null, - OnDemandSandboxActivityTracker? activityTracker = null, + SandboxActivityTracker? activityTracker = null, Random? reconnectJitter = null) { this.transport = Check.NotNull(transport); @@ -59,7 +59,7 @@ public OnDemandSandboxActivityWorkerRegistrationHostedService( /// public Task StartAsync(CancellationToken cancellationToken) { - string[] activityNames = OnDemandSandboxActivityMetadata.ResolveActivityNames(this.registeredActivityNames); + string[] activityNames = SandboxActivityMetadata.ResolveActivityNames(this.registeredActivityNames); CancellationTokenSource registrationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); Task registrationPump = Task.Run( () => this.RunRegistrationLoopAsync(activityNames.Length, registrationCts.Token), @@ -77,7 +77,7 @@ public Task StartAsync(CancellationToken cancellationToken) public async Task StopAsync(CancellationToken cancellationToken) { CancellationTokenSource? localCts; - IOnDemandSandboxActivityWorkerSession? localSession; + ISandboxActivityWorkerSession? localSession; Task? localPump; lock (this.sync) { @@ -159,7 +159,7 @@ internal static TimeSpan ComputeJitteredReconnectDelay(TimeSpan retryDelay, Rand } static async ValueTask DisposeSessionAsync( - IOnDemandSandboxActivityWorkerSession registrationSession, + ISandboxActivityWorkerSession registrationSession, ILogger logger) { try @@ -187,13 +187,13 @@ async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancell TimeSpan retryDelay = this.GetInitialRetryDelay(); while (!cancellationToken.IsCancellationRequested) { - IOnDemandSandboxActivityWorkerSession? registrationSession = null; + ISandboxActivityWorkerSession? registrationSession = null; try { registrationSession = this.transport.OpenOnDemandSandboxActivityWorkerSession(this.options.TaskHub, cancellationToken); this.SetCurrentSession(registrationSession); - Proto.OnDemandSandboxActivityWorkerMessage startMessage = OnDemandSandboxWorkerMessageBuilder.BuildWorkerStart(this.options, this.registeredActivityNames); + Proto.OnDemandSandboxActivityWorkerMessage startMessage = SandboxWorkerMessageBuilder.BuildWorkerStart(this.options, this.registeredActivityNames); await this.WriteSessionMessageAsync(registrationSession, startMessage, cancellationToken).ConfigureAwait(false); Logs.OnDemandSandboxActivityWorkerRegistered( this.logger, @@ -265,7 +265,7 @@ async Task HandleRetriableRegistrationFailureAsync( } async Task RunRegistrationSessionAsync( - IOnDemandSandboxActivityWorkerSession registrationSession, + ISandboxActivityWorkerSession registrationSession, CancellationToken cancellationToken) { using CancellationTokenSource heartbeatCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -308,7 +308,7 @@ async Task RunRegistrationSessionAsync( } async Task PumpHeartbeatsAsync( - IOnDemandSandboxActivityWorkerSession registrationSession, + ISandboxActivityWorkerSession registrationSession, CancellationToken cancellationToken) { using PeriodicTimer timer = new(this.options.HeartbeatInterval); @@ -317,13 +317,13 @@ async Task PumpHeartbeatsAsync( int activeActivitiesCount = this.activityTracker?.InFlightCount ?? 0; await this.WriteSessionMessageAsync( registrationSession, - OnDemandSandboxWorkerMessageBuilder.BuildWorkerHeartbeat(activeActivitiesCount), + SandboxWorkerMessageBuilder.BuildWorkerHeartbeat(activeActivitiesCount), cancellationToken).ConfigureAwait(false); } } async Task WriteSessionMessageAsync( - IOnDemandSandboxActivityWorkerSession registrationSession, + ISandboxActivityWorkerSession registrationSession, Proto.OnDemandSandboxActivityWorkerMessage message, CancellationToken cancellationToken) { @@ -340,7 +340,7 @@ async Task WriteSessionMessageAsync( } async Task CompleteSessionAsync( - IOnDemandSandboxActivityWorkerSession registrationSession, + ISandboxActivityWorkerSession registrationSession, CancellationToken cancellationToken) { await this.streamSync.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -354,7 +354,7 @@ async Task CompleteSessionAsync( } } - void SetCurrentSession(IOnDemandSandboxActivityWorkerSession registrationSession) + void SetCurrentSession(ISandboxActivityWorkerSession registrationSession) { lock (this.sync) { @@ -362,7 +362,7 @@ void SetCurrentSession(IOnDemandSandboxActivityWorkerSession registrationSession } } - void ClearCurrentSession(IOnDemandSandboxActivityWorkerSession registrationSession) + void ClearCurrentSession(ISandboxActivityWorkerSession registrationSession) { lock (this.sync) { diff --git a/src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs b/src/Worker/AzureManaged.Sandboxes/SandboxWorkerMessageBuilder.cs similarity index 87% rename from src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs rename to src/Worker/AzureManaged.Sandboxes/SandboxWorkerMessageBuilder.cs index e22922b5..deb69a1e 100644 --- a/src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerMessageBuilder.cs +++ b/src/Worker/AzureManaged.Sandboxes/SandboxWorkerMessageBuilder.cs @@ -4,12 +4,12 @@ using Microsoft.DurableTask.AzureManaged.Internal; using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; -namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; +namespace Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; /// /// Builds on-demand sandbox activity worker registration protocol messages. /// -static class OnDemandSandboxWorkerMessageBuilder +static class SandboxWorkerMessageBuilder { /// /// Builds the initial on-demand sandbox activity worker registration message. @@ -18,16 +18,16 @@ static class OnDemandSandboxWorkerMessageBuilder /// The activity handlers registered by the worker process. /// The worker start protocol message. public static Proto.OnDemandSandboxActivityWorkerMessage BuildWorkerStart( - OnDemandSandboxWorkerRuntimeOptions options, + SandboxWorkerRuntimeOptions options, IReadOnlyCollection registeredActivityNames) { Check.NotNull(options); Check.NotNull(registeredActivityNames); - string taskHub = OnDemandSandboxActivityMetadata.NormalizeRequired( + string taskHub = SandboxActivityMetadata.NormalizeRequired( options.TaskHub, "On-demand sandbox activity worker registration requires a task hub name."); - string[] activityNames = OnDemandSandboxActivityMetadata.ResolveActivityNames(registeredActivityNames); + string[] activityNames = SandboxActivityMetadata.ResolveActivityNames(registeredActivityNames); if (activityNames.Length == 0) { throw new InvalidOperationException("On-demand sandbox activity worker registration requires at least one registered activity."); @@ -38,10 +38,10 @@ public static Proto.OnDemandSandboxActivityWorkerMessage BuildWorkerStart( throw new InvalidOperationException("On-demand sandbox activity worker max activity count must be greater than zero."); } - string workerProfileId = OnDemandSandboxActivityMetadata.NormalizeWorkerProfileId( + string workerProfileId = SandboxActivityMetadata.NormalizeWorkerProfileId( options.WorkerProfileId, "On-demand sandbox activity worker registration requires a worker profile ID."); - string dtsSandboxIdentifier = OnDemandSandboxActivityMetadata.NormalizeRequired( + string dtsSandboxIdentifier = SandboxActivityMetadata.NormalizeRequired( Environment.GetEnvironmentVariable("DTS_SANDBOX_ID") ?? string.Empty, "On-demand sandbox activity worker registration requires a DTS sandbox ID."); diff --git a/src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs b/src/Worker/AzureManaged.Sandboxes/SandboxWorkerRuntimeOptions.cs similarity index 85% rename from src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs rename to src/Worker/AzureManaged.Sandboxes/SandboxWorkerRuntimeOptions.cs index e78ad29b..82cbd645 100644 --- a/src/Worker/AzureManaged.OnDemandSandbox/OnDemandSandboxWorkerRuntimeOptions.cs +++ b/src/Worker/AzureManaged.Sandboxes/SandboxWorkerRuntimeOptions.cs @@ -3,12 +3,12 @@ using Microsoft.DurableTask.AzureManaged.Internal; -namespace Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; +namespace Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; /// /// Internal runtime settings for an on-demand sandbox worker process. /// -internal sealed class OnDemandSandboxWorkerRuntimeOptions +internal sealed class SandboxWorkerRuntimeOptions { /// /// Gets or sets the task hub used by on-demand sandbox worker registration. @@ -18,7 +18,7 @@ internal sealed class OnDemandSandboxWorkerRuntimeOptions /// /// Gets or sets the worker profile ID used by on-demand sandbox worker registration. /// - public string WorkerProfileId { get; set; } = OnDemandSandboxActivityMetadata.DefaultWorkerProfileId; + public string WorkerProfileId { get; set; } = SandboxActivityMetadata.DefaultWorkerProfileId; /// /// Gets or sets the maximum number of concurrent activities expected from this on-demand sandbox worker. diff --git a/src/Worker/AzureManaged.OnDemandSandbox/Worker.AzureManaged.OnDemandSandbox.csproj b/src/Worker/AzureManaged.Sandboxes/Worker.AzureManaged.Sandboxes.csproj similarity index 75% rename from src/Worker/AzureManaged.OnDemandSandbox/Worker.AzureManaged.OnDemandSandbox.csproj rename to src/Worker/AzureManaged.Sandboxes/Worker.AzureManaged.Sandboxes.csproj index c1c31099..6fbe8aec 100644 --- a/src/Worker/AzureManaged.OnDemandSandbox/Worker.AzureManaged.OnDemandSandbox.csproj +++ b/src/Worker/AzureManaged.Sandboxes/Worker.AzureManaged.Sandboxes.csproj @@ -1,10 +1,9 @@ - net6.0;net8.0;net10.0 + net10.0 Azure Managed on-demand sandbox activity worker extensions for the Durable Task Framework worker. - Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox - Microsoft.DurableTask + Microsoft.DurableTask.Worker.AzureManaged.Sandboxes true @@ -20,7 +19,7 @@ - + diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs index 28edc82e..a172b2ef 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs @@ -405,7 +405,7 @@ void DispatchWorkItem(P.WorkItem workItem, CancellationToken cancellation) } else if (workItem.RequestCase == P.WorkItem.RequestOneofCase.ActivityRequest) { - this.internalOptions.NotifyActivity?.Invoke(ActivityNotificationPhase.Started); + this.NotifyActivity(ActivityNotificationPhase.Started); this.RunBackgroundTask( workItem, async () => @@ -419,7 +419,7 @@ await this.OnRunActivityAsync( } finally { - this.internalOptions.NotifyActivity?.Invoke(ActivityNotificationPhase.Completed); + this.NotifyActivity(ActivityNotificationPhase.Completed); } }, cancellation); @@ -460,6 +460,18 @@ await this.OnRunActivityAsync( } } + void NotifyActivity(ActivityNotificationPhase phase) + { + try + { + this.internalOptions.NotifyActivity?.Invoke(phase); + } + catch (Exception ex) + { + this.Logger.ActivityNotificationFailed(phase, ex); + } + } + void RunBackgroundTask(P.WorkItem? workItem, Func handler, CancellationToken cancellation) { // TODO: is Task.Run appropriate here? Should we have finer control over the tasks and their threads? diff --git a/src/Worker/Grpc/Logs.cs b/src/Worker/Grpc/Logs.cs index 878efe9c..f378a7fc 100644 --- a/src/Worker/Grpc/Logs.cs +++ b/src/Worker/Grpc/Logs.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.DurableTask.Worker.Grpc.Internal; using Microsoft.Extensions.Logging; namespace Microsoft.DurableTask.Worker.Grpc @@ -99,9 +100,12 @@ static partial class Logs public static partial void ReceivedHealthPing(this ILogger logger); [LoggerMessage(EventId = 76, Level = LogLevel.Information, Message = "Work-item stream ended by the backend (graceful close). Will reconnect.")] - public static partial void StreamEndedByPeer(this ILogger logger); - + public static partial void StreamEndedByPeer(this ILogger logger); + [LoggerMessage(EventId = 77, Level = LogLevel.Warning, Message = "Transient gRPC error for '{OperationName}'. Attempt {Attempt} of {MaxAttempts}. Retrying in {BackoffMs} ms. StatusCode={StatusCode}")] public static partial void TransientGrpcRetry(this ILogger logger, string operationName, int attempt, int maxAttempts, double backoffMs, int statusCode, Exception exception); + + [LoggerMessage(EventId = 78, Level = LogLevel.Warning, Message = "Activity notification callback failed for phase '{Phase}'.")] + public static partial void ActivityNotificationFailed(this ILogger logger, ActivityNotificationPhase phase, Exception exception); } } diff --git a/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj b/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj index 2579a784..b9b8cd70 100644 --- a/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj +++ b/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/test/Client/AzureManaged.Tests/OnDemandSandboxActivitiesClientTests.cs b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs similarity index 73% rename from test/Client/AzureManaged.Tests/OnDemandSandboxActivitiesClientTests.cs rename to test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs index 074c9f1d..7ae71c66 100644 --- a/test/Client/AzureManaged.Tests/OnDemandSandboxActivitiesClientTests.cs +++ b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs @@ -13,27 +13,27 @@ namespace Microsoft.DurableTask.Client.AzureManaged.Tests; -public class OnDemandSandboxActivitiesClientTests +public class SandboxActivitiesClientTests { const string TaskHub = "testhub"; [Fact] public void OnDemandSandboxDeclarationContract_DoesNotExposeRemovedOptions() { - typeof(OnDemandSandboxOptions).GetProperty("LaunchCommand").Should().BeNull(); - typeof(OnDemandSandboxOptions).GetProperty("DeclarationRetryMaxAttempts").Should().BeNull(); - typeof(OnDemandSandboxOptions).GetProperty("DeclarationRetryDelay").Should().BeNull(); - typeof(OnDemandSandboxOptions).GetProperty( + typeof(SandboxWorkerProfileOptions).GetProperty("LaunchCommand").Should().BeNull(); + typeof(SandboxWorkerProfileOptions).GetProperty("DeclarationRetryMaxAttempts").Should().BeNull(); + typeof(SandboxWorkerProfileOptions).GetProperty("DeclarationRetryDelay").Should().BeNull(); + typeof(SandboxWorkerProfileOptions).GetProperty( "HeartbeatInterval", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); - typeof(OnDemandSandboxOptions).GetProperty("WakeupPort").Should().BeNull(); - typeof(OnDemandSandboxOptions).GetProperty( + typeof(SandboxWorkerProfileOptions).GetProperty("WakeupPort").Should().BeNull(); + typeof(SandboxWorkerProfileOptions).GetProperty( "WorkerRegistrationRetryInitialDelay", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); - typeof(OnDemandSandboxOptions).GetProperty( + typeof(SandboxWorkerProfileOptions).GetProperty( "WorkerRegistrationRetryMaxDelay", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); - typeof(OnDemandSandboxOptions).GetProperty( + typeof(SandboxWorkerProfileOptions).GetProperty( "Mode", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); typeof(OnDemandSandboxActivityDeclaration).GetProperty("LaunchCommand").Should().BeNull(); @@ -43,9 +43,9 @@ public void OnDemandSandboxDeclarationContract_DoesNotExposeRemovedOptions() public void OnDemandSandboxDeclarationContract_ExposesProfileAddActivityOnly() { // Arrange - Type optionsType = typeof(OnDemandSandboxOptions); - Type? activityAttributeType = typeof(OnDemandSandboxOptions).Assembly.GetType( - "Microsoft.DurableTask.Client.AzureManaged.OnDemandSandboxActivityAttribute"); + Type optionsType = typeof(SandboxWorkerProfileOptions); + Type? activityAttributeType = typeof(SandboxWorkerProfileOptions).Assembly.GetType( + "Microsoft.DurableTask.Client.AzureManaged.SandboxesActivityAttribute"); // Act/Assert optionsType.GetProperty("ActivityNames").Should().BeNull(); @@ -59,19 +59,19 @@ public void OnDemandSandboxDeclarationContract_ExposesProfileAddActivityOnly() [InlineData("500m", "1024Mi")] [InlineData("0.5", "1Gi")] [InlineData("2", "2048")] - public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_AcceptsAdcResourceQuantities( + public void SandboxActivityDeclarationBuilder_BuildDeclaration_AcceptsAdcResourceQuantities( string cpu, string memory) { // Arrange - OnDemandSandboxOptions options = CreateDeclarationOptions(); + SandboxWorkerProfileOptions options = CreateDeclarationOptions(); options.Cpu = cpu; options.Memory = memory; // Act - OnDemandSandboxActivityDeclaration declaration = OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( + OnDemandSandboxActivityDeclaration declaration = SandboxActivityDeclarationBuilder.BuildDeclaration( options, - OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); + SandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); // Assert declaration.Resources.Cpu.Should().Be(cpu); @@ -85,20 +85,20 @@ public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_AcceptsAd [InlineData("500m", "0", "memory")] [InlineData("500m", "0Mi", "memory")] [InlineData("500m", "500m", "memory")] - public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_RejectsInvalidAdcResourceQuantities( + public void SandboxActivityDeclarationBuilder_BuildDeclaration_RejectsInvalidAdcResourceQuantities( string cpu, string memory, string expectedMessage) { // Arrange - OnDemandSandboxOptions options = CreateDeclarationOptions(); + SandboxWorkerProfileOptions options = CreateDeclarationOptions(); options.Cpu = cpu; options.Memory = memory; // Act - Action action = () => OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( + Action action = () => SandboxActivityDeclarationBuilder.BuildDeclaration( options, - OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); + SandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); // Assert action.Should().Throw() @@ -106,16 +106,16 @@ public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_RejectsIn } [Fact] - public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_RequiresSchedulerManagedIdentityClientId() + public void SandboxActivityDeclarationBuilder_BuildDeclaration_RequiresSchedulerManagedIdentityClientId() { // Arrange - OnDemandSandboxOptions options = CreateDeclarationOptions(); + SandboxWorkerProfileOptions options = CreateDeclarationOptions(); options.SchedulerManagedIdentityClientId = " "; // Act - Action action = () => OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( + Action action = () => SandboxActivityDeclarationBuilder.BuildDeclaration( options, - OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); + SandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); // Assert action.Should().Throw() @@ -123,16 +123,16 @@ public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_RequiresS } [Fact] - public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_RequiresImagePullManagedIdentityClientId() + public void SandboxActivityDeclarationBuilder_BuildDeclaration_RequiresImagePullManagedIdentityClientId() { // Arrange - OnDemandSandboxOptions options = CreateDeclarationOptions(); + SandboxWorkerProfileOptions options = CreateDeclarationOptions(); options.ImagePullManagedIdentityClientId = " "; // Act - Action action = () => OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( + Action action = () => SandboxActivityDeclarationBuilder.BuildDeclaration( options, - OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); + SandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); // Assert action.Should().Throw() @@ -140,7 +140,7 @@ public void OnDemandSandboxActivityDeclarationBuilder_BuildDeclaration_RequiresI } [Fact] - public void OnDemandSandboxActivityDeclarationProvider_ResolveDeclarations_UsesWorkerProfileConfigure() + public void SandboxActivityDeclarationProvider_ResolveDeclarations_UsesWorkerProfileConfigure() { // Arrange using EnvironmentVariableScope image = new("DTS_ON_DEMAND_SANDBOX_ACTIVITY_IMAGE", "example.com/not-used:latest"); @@ -148,14 +148,14 @@ public void OnDemandSandboxActivityDeclarationProvider_ResolveDeclarations_UsesW using EnvironmentVariableScope memory = new("DTS_ON_DEMAND_SANDBOX_MEMORY", "4096Mi"); using EnvironmentVariableScope maxActivities = new("DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES", "99"); - OnDemandSandboxActivityDeclarationProvider provider = new(); + SandboxActivityDeclarationProvider provider = new(); // Act - OnDemandSandboxOptions options = provider.ResolveDeclarations(TaskHub) + SandboxWorkerProfileOptions options = provider.ResolveDeclarations(TaskHub) .Single(options => options.WorkerProfileId == "annotated-profile"); - OnDemandSandboxActivityDeclaration declaration = OnDemandSandboxActivityDeclarationBuilder.BuildDeclaration( + OnDemandSandboxActivityDeclaration declaration = SandboxActivityDeclarationBuilder.BuildDeclaration( options, - OnDemandSandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); + SandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); // Assert declaration.WorkerProfileId.Should().Be("annotated-profile"); @@ -172,11 +172,11 @@ public void OnDemandSandboxActivityDeclarationProvider_ResolveDeclarations_UsesW } [Fact] - public void OnDemandSandboxActivityDeclarationProvider_ValidateProfileType_RequiresProfileInterface() + public void SandboxActivityDeclarationProvider_ValidateProfileType_RequiresProfileInterface() { // Arrange // Act - Action action = () => OnDemandSandboxActivityDeclarationProvider.ValidateProfileType(typeof(ProfileWithoutInterface)); + Action action = () => SandboxActivityDeclarationProvider.ValidateProfileType(typeof(ProfileWithoutInterface)); // Assert action.Should().Throw() @@ -184,7 +184,7 @@ public void OnDemandSandboxActivityDeclarationProvider_ValidateProfileType_Requi } [Fact] - public async Task AddDurableTaskSchedulerOnDemandSandboxActivitiesClient_UsesConfiguredDurableTaskClientInvoker() + public async Task AddDurableTaskSchedulerSandboxActivitiesClient_UsesConfiguredDurableTaskClientInvoker() { // Arrange RecordingOnDemandSandboxLogCallInvoker callInvoker = new(); @@ -193,13 +193,13 @@ public async Task AddDurableTaskSchedulerOnDemandSandboxActivitiesClient_UsesCon .Configure(options => options.TaskHubName = "client-test-taskhub"); services.AddOptions(Options.DefaultName) .Configure(options => options.CallInvoker = callInvoker); - services.AddDurableTaskSchedulerOnDemandSandboxActivitiesClient(); + services.AddDurableTaskSchedulerSandboxActivitiesClient(); using ServiceProvider provider = services.BuildServiceProvider(); - OnDemandSandboxActivitiesClient client = provider.GetRequiredService(); + SandboxActivitiesClient client = provider.GetRequiredService(); // Act - await client.RemoveOnDemandSandboxActivityDeclarationAsync("default"); + await client.RemoveSandboxActivityDeclarationAsync("default"); // Assert callInvoker.RemoveRequest.Should().NotBeNull(); @@ -207,17 +207,17 @@ public async Task AddDurableTaskSchedulerOnDemandSandboxActivitiesClient_UsesCon } [Fact] - public async Task EnableOnDemandSandboxActivitiesAsync_SendsWorkerProfileDeclarations() + public async Task EnableSandboxActivitiesAsync_SendsWorkerProfileDeclarations() { // Arrange RecordingOnDemandSandboxLogCallInvoker callInvoker = new(); - OnDemandSandboxActivitiesClient client = new( - new OnDemandSandboxActivitiesGrpcTransport(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)), + SandboxActivitiesClient client = new( + new SandboxActivitiesGrpcTransport(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)), "client-test-taskhub", - new OnDemandSandboxActivityDeclarationProvider()); + new SandboxActivityDeclarationProvider()); // Act - await client.EnableOnDemandSandboxActivitiesAsync(); + await client.EnableSandboxActivitiesAsync(); // Assert OnDemandSandboxActivityDeclaration declaration = callInvoker.DeclareRequests @@ -310,9 +310,9 @@ AsyncUnaryCall CreateUnaryCall(TResponse response) } } - static OnDemandSandboxOptions CreateDeclarationOptions() + static SandboxWorkerProfileOptions CreateDeclarationOptions() { - OnDemandSandboxOptions options = new() + SandboxWorkerProfileOptions options = new() { TaskHub = TaskHub, WorkerProfileId = "profile-a", @@ -327,10 +327,10 @@ static OnDemandSandboxOptions CreateDeclarationOptions() return options; } - [OnDemandSandboxWorkerProfile("client-test-profile")] + [SandboxWorkerProfile("client-test-profile")] sealed class ClientTestWorkerProfile : ISandboxWorkerProfile { - public void Configure(OnDemandSandboxOptions options) + public void Configure(SandboxWorkerProfileOptions options) { options.ContainerImage = "example.com/client-test-worker:latest"; options.ImagePullManagedIdentityClientId = "image-pull-client-id"; @@ -342,12 +342,12 @@ public void Configure(OnDemandSandboxOptions options) } } - [OnDemandSandboxWorkerProfile("annotated-profile")] + [SandboxWorkerProfile("annotated-profile")] sealed class AnnotatedWorkerProfile : ISandboxWorkerProfile { public static int ConfigureCallCount { get; private set; } - public void Configure(OnDemandSandboxOptions options) + public void Configure(SandboxWorkerProfileOptions options) { ConfigureCallCount++; options.ContainerImage = "example.com/repo/annotated-worker:latest"; diff --git a/test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs similarity index 88% rename from test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs rename to test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs index cd23cc40..3da503d6 100644 --- a/test/Worker/AzureManaged.Tests/OnDemandSandboxActivitiesTests.cs +++ b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs @@ -7,7 +7,7 @@ using Microsoft.DurableTask.AzureManaged.Internal; using Microsoft.DurableTask.Protobuf.OnDemandSandbox; using Microsoft.DurableTask.Worker.AzureManaged; -using Microsoft.DurableTask.Worker.AzureManaged.OnDemandSandbox; +using Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; @@ -22,11 +22,11 @@ public class OnDemandSandboxActivitiesTests const string TaskHub = "testhub"; [Fact] - public async Task OnDemandSandboxActivitiesGrpcTransport_SendsTaskHubMetadata() + public async Task SandboxActivitiesGrpcTransport_SendsTaskHubMetadata() { // Arrange RecordingOnDemandSandboxActivitiesCallInvoker callInvoker = new(); - OnDemandSandboxActivitiesGrpcTransport transport = new(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)); + SandboxActivitiesGrpcTransport transport = new(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)); OnDemandSandboxActivityDeclaration declaration = new() { WorkerProfileId = "profile-a", @@ -45,7 +45,7 @@ public async Task OnDemandSandboxActivitiesGrpcTransport_SendsTaskHubMetadata() // Act await transport.DeclareOnDemandSandboxActivitiesAsync(declaration, TaskHub, CancellationToken.None); - await using IOnDemandSandboxActivityWorkerSession session = transport.OpenOnDemandSandboxActivityWorkerSession( + await using ISandboxActivityWorkerSession session = transport.OpenOnDemandSandboxActivityWorkerSession( TaskHub, CancellationToken.None); @@ -55,11 +55,11 @@ public async Task OnDemandSandboxActivitiesGrpcTransport_SendsTaskHubMetadata() } [Fact] - public async Task OnDemandSandboxActivitiesGrpcTransport_CanRelyOnChannelTaskHubMetadata() + public async Task SandboxActivitiesGrpcTransport_CanRelyOnChannelTaskHubMetadata() { // Arrange RecordingOnDemandSandboxActivitiesCallInvoker callInvoker = new(); - OnDemandSandboxActivitiesGrpcTransport transport = new( + SandboxActivitiesGrpcTransport transport = new( new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker), attachTaskHubMetadata: false); OnDemandSandboxActivityDeclaration declaration = new() @@ -80,7 +80,7 @@ public async Task OnDemandSandboxActivitiesGrpcTransport_CanRelyOnChannelTaskHub // Act await transport.DeclareOnDemandSandboxActivitiesAsync(declaration, TaskHub, CancellationToken.None); - await using IOnDemandSandboxActivityWorkerSession session = transport.OpenOnDemandSandboxActivityWorkerSession( + await using ISandboxActivityWorkerSession session = transport.OpenOnDemandSandboxActivityWorkerSession( TaskHub, CancellationToken.None); @@ -90,7 +90,7 @@ public async Task OnDemandSandboxActivitiesGrpcTransport_CanRelyOnChannelTaskHub } [Fact] - public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_SendsLiveWorkerMetadataWithRegisteredActivities() + public async Task SandboxActivityWorkerRegistrationHostedService_SendsLiveWorkerMetadataWithRegisteredActivities() { // Arrange string? originalSubstrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); @@ -100,7 +100,7 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_SendsLi try { - OnDemandSandboxWorkerRuntimeOptions options = new() + SandboxWorkerRuntimeOptions options = new() { TaskHub = TaskHub, WorkerProfileId = "profile-a", @@ -108,11 +108,11 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_SendsLi HeartbeatInterval = TimeSpan.FromDays(1), }; FakeOnDemandSandboxActivitiesTransport client = new(); - OnDemandSandboxActivityWorkerRegistrationHostedService service = new( + SandboxActivityWorkerRegistrationHostedService service = new( client, options, ["RemoteHello"], - NullLogger.Instance); + NullLogger.Instance); // Act await service.StartAsync(CancellationToken.None); @@ -138,7 +138,7 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_SendsLi } [Fact] - public void OnDemandSandboxWorkerMessageBuilder_NormalizesTaskHubAndSandboxId() + public void SandboxWorkerMessageBuilder_NormalizesTaskHubAndSandboxId() { // Arrange string? originalSandboxId = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID"); @@ -146,7 +146,7 @@ public void OnDemandSandboxWorkerMessageBuilder_NormalizesTaskHubAndSandboxId() try { - OnDemandSandboxWorkerRuntimeOptions options = new() + SandboxWorkerRuntimeOptions options = new() { TaskHub = " testhub ", WorkerProfileId = "profile-a", @@ -154,7 +154,7 @@ public void OnDemandSandboxWorkerMessageBuilder_NormalizesTaskHubAndSandboxId() }; // Act - OnDemandSandboxActivityWorkerMessage message = OnDemandSandboxWorkerMessageBuilder.BuildWorkerStart( + OnDemandSandboxActivityWorkerMessage message = SandboxWorkerMessageBuilder.BuildWorkerStart( options, ["RemoteHello"]); @@ -173,7 +173,7 @@ public void OnDemandSandboxWorkerMessageBuilder_NormalizesTaskHubAndSandboxId() [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void OnDemandSandboxWorkerMessageBuilder_MissingSandboxId_Throws(string? sandboxId) + public void SandboxWorkerMessageBuilder_MissingSandboxId_Throws(string? sandboxId) { // Arrange string? originalSandboxId = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID"); @@ -181,7 +181,7 @@ public void OnDemandSandboxWorkerMessageBuilder_MissingSandboxId_Throws(string? try { - OnDemandSandboxWorkerRuntimeOptions options = new() + SandboxWorkerRuntimeOptions options = new() { TaskHub = TaskHub, WorkerProfileId = "profile-a", @@ -189,7 +189,7 @@ public void OnDemandSandboxWorkerMessageBuilder_MissingSandboxId_Throws(string? }; // Act - Action action = () => OnDemandSandboxWorkerMessageBuilder.BuildWorkerStart( + Action action = () => SandboxWorkerMessageBuilder.BuildWorkerStart( options, ["RemoteHello"]); @@ -204,10 +204,10 @@ public void OnDemandSandboxWorkerMessageBuilder_MissingSandboxId_Throws(string? } [Fact] - public void OnDemandSandboxActivityTracker_TracksInFlightActivityCount() + public void SandboxActivityTracker_TracksInFlightActivityCount() { // Arrange - OnDemandSandboxActivityTracker activityTracker = new(); + SandboxActivityTracker activityTracker = new(); // Act activityTracker.NotifyActivityStarted(); @@ -231,11 +231,11 @@ public void OnDemandSandboxActivityTracker_TracksInFlightActivityCount() } [Fact] - public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_SendsHeartbeatWithCurrentInFlightCount() + public async Task SandboxActivityWorkerRegistrationHostedService_SendsHeartbeatWithCurrentInFlightCount() { // Arrange using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); - OnDemandSandboxWorkerRuntimeOptions options = new() + SandboxWorkerRuntimeOptions options = new() { TaskHub = TaskHub, WorkerProfileId = "profile-a", @@ -244,15 +244,15 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_SendsHe }; FakeOnDemandSandboxActivitiesTransport client = new(); - OnDemandSandboxActivityTracker activityTracker = new(); + SandboxActivityTracker activityTracker = new(); activityTracker.NotifyActivityStarted(); activityTracker.NotifyActivityStarted(); - OnDemandSandboxActivityWorkerRegistrationHostedService service = new( + SandboxActivityWorkerRegistrationHostedService service = new( client, options, ["RemoteHello"], - NullLogger.Instance, + NullLogger.Instance, activityTracker: activityTracker); // Act @@ -268,11 +268,11 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_SendsHe } [Fact] - public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_ReopensSessionAfterTransientStreamFailure() + public async Task SandboxActivityWorkerRegistrationHostedService_ReopensSessionAfterTransientStreamFailure() { // Arrange using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); - OnDemandSandboxWorkerRuntimeOptions options = new() + SandboxWorkerRuntimeOptions options = new() { TaskHub = TaskHub, WorkerProfileId = "profile-a", @@ -288,11 +288,11 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_Reopens client.QueueSession(failedSession); client.QueueSession(recoveredSession); - OnDemandSandboxActivityWorkerRegistrationHostedService service = new( + SandboxActivityWorkerRegistrationHostedService service = new( client, options, ["RemoteHello"], - NullLogger.Instance); + NullLogger.Instance); // Act await service.StartAsync(CancellationToken.None); @@ -307,11 +307,11 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_Reopens } [Fact] - public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_ReopensSessionAfterTerminalServerFailure() + public async Task SandboxActivityWorkerRegistrationHostedService_ReopensSessionAfterTerminalServerFailure() { // Arrange using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); - OnDemandSandboxWorkerRuntimeOptions options = new() + SandboxWorkerRuntimeOptions options = new() { TaskHub = TaskHub, WorkerProfileId = "profile-a", @@ -327,11 +327,11 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_Reopens client.QueueSession(failedSession); client.QueueSession(recoveredSession); - OnDemandSandboxActivityWorkerRegistrationHostedService service = new( + SandboxActivityWorkerRegistrationHostedService service = new( client, options, ["RemoteHello"], - NullLogger.Instance); + NullLogger.Instance); // Act await service.StartAsync(CancellationToken.None); @@ -347,22 +347,22 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_Reopens } [Fact] - public void OnDemandSandboxActivityWorkerRegistrationHostedService_ComputeJitteredReconnectDelay_UsesFullJitterWindow() + public void SandboxActivityWorkerRegistrationHostedService_ComputeJitteredReconnectDelay_UsesFullJitterWindow() { // Arrange TimeSpan retryDelay = TimeSpan.FromSeconds(10); // Act - TimeSpan zero = OnDemandSandboxActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( + TimeSpan zero = SandboxActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( TimeSpan.Zero, new DeterministicRandom(0.5)); - TimeSpan low = OnDemandSandboxActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( + TimeSpan low = SandboxActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( retryDelay, new DeterministicRandom(0.0)); - TimeSpan mid = OnDemandSandboxActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( + TimeSpan mid = SandboxActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( retryDelay, new DeterministicRandom(0.5)); - TimeSpan high = OnDemandSandboxActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( + TimeSpan high = SandboxActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( retryDelay, new DeterministicRandom(0.999999)); @@ -375,11 +375,11 @@ public void OnDemandSandboxActivityWorkerRegistrationHostedService_ComputeJitter } [Fact] - public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_AppliesJitterToReconnectDelay() + public async Task SandboxActivityWorkerRegistrationHostedService_AppliesJitterToReconnectDelay() { // Arrange using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); - OnDemandSandboxWorkerRuntimeOptions options = new() + SandboxWorkerRuntimeOptions options = new() { TaskHub = TaskHub, WorkerProfileId = "profile-a", @@ -395,11 +395,11 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_Applies client.QueueSession(failedSession); client.QueueSession(recoveredSession); - OnDemandSandboxActivityWorkerRegistrationHostedService service = new( + SandboxActivityWorkerRegistrationHostedService service = new( client, options, ["RemoteHello"], - NullLogger.Instance, + NullLogger.Instance, reconnectJitter: new DeterministicRandom(0.0)); // Act @@ -413,11 +413,11 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_Applies } [Fact] - public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_StopAsync_DoesNotCompleteStreamWhileWriteIsInFlight() + public async Task SandboxActivityWorkerRegistrationHostedService_StopAsync_DoesNotCompleteStreamWhileWriteIsInFlight() { // Arrange using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); - OnDemandSandboxWorkerRuntimeOptions options = new() + SandboxWorkerRuntimeOptions options = new() { TaskHub = TaskHub, WorkerProfileId = "profile-a", @@ -429,11 +429,11 @@ public async Task OnDemandSandboxActivityWorkerRegistrationHostedService_StopAsy FakeOnDemandSandboxActivitiesTransport client = new(); client.QueueSession(session); - OnDemandSandboxActivityWorkerRegistrationHostedService service = new( + SandboxActivityWorkerRegistrationHostedService service = new( client, options, ["RemoteHello"], - NullLogger.Instance); + NullLogger.Instance); // Act await service.StartAsync(CancellationToken.None); @@ -701,7 +701,7 @@ public async Task UseSandboxWorker_InvalidInjectedSubstrate_ThrowsWhenWorkerOpti .WithMessage("DTS_SUBSTRATE must be 'Sandbox' or 'AcaSessionPool' for on-demand sandbox workers."); } - sealed class FakeOnDemandSandboxActivitiesTransport : IOnDemandSandboxActivitiesTransport + sealed class FakeOnDemandSandboxActivitiesTransport : ISandboxActivitiesTransport { readonly Queue queuedSessions = new(); @@ -721,7 +721,7 @@ public Task DeclareOnDemandSandboxActi throw new NotSupportedException(); } - public Task RemoveOnDemandSandboxActivityDeclarationAsync( + public Task RemoveSandboxActivityDeclarationAsync( string workerProfileId, string taskHub, CancellationToken cancellationToken) @@ -729,7 +729,7 @@ public Task RemoveOnDemandSandbo throw new NotSupportedException(); } - public IOnDemandSandboxActivityWorkerSession OpenOnDemandSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken) + public ISandboxActivityWorkerSession OpenOnDemandSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken) { this.SessionTaskHubs.Add(taskHub); FakeOnDemandSandboxActivityWorkerSession session = this.queuedSessions.Count > 0 @@ -816,7 +816,7 @@ sealed class RecordingClientStreamWriter : IClientStreamWriter public Task CompleteAsync() => Task.CompletedTask; } - sealed class FakeOnDemandSandboxActivityWorkerSession : IOnDemandSandboxActivityWorkerSession + sealed class FakeOnDemandSandboxActivityWorkerSession : ISandboxActivityWorkerSession { readonly object sync = new(); readonly TaskCompletionSource completion = diff --git a/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj b/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj index ecd3f7d6..fe1183c3 100644 --- a/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj +++ b/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj @@ -11,7 +11,7 @@ - + diff --git a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs index c3863398..1440a23d 100644 --- a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs +++ b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs @@ -306,6 +306,58 @@ public async Task DispatchWorkItem_ActivityRequest_NotifiesActivityStartAndCompl notifications.Should().Equal(ActivityNotificationPhase.Started, ActivityNotificationPhase.Completed); } + [Fact] + public async Task DispatchWorkItem_ActivityRequest_NotificationFailure_CompletesActivity() + { + // Arrange + TaskCompletionSource activityCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); + GrpcDurableTaskWorkerOptions grpcOptions = new(); + grpcOptions.ConfigureActivityNotification(phase => throw new InvalidOperationException($"Notification failed: {phase}")); + + P.WorkItem activityWorkItem = new() + { + ActivityRequest = new P.ActivityRequest + { + Name = "MyActivity", + TaskId = 42, + OrchestrationInstance = new P.OrchestrationInstance + { + InstanceId = "instance1", + ExecutionId = "execution1", + }, + }, + CompletionToken = "completion1", + }; + + DurableTaskWorkerOptions workerOptions = new() + { + Logging = { UseLegacyCategories = false }, + }; + TestLogProvider logProvider = new(new NullOutput()); + GrpcDurableTaskWorker worker = CreateWorker(grpcOptions, workerOptions, new SimpleLoggerFactory(logProvider)); + Mock clientMock = new( + MockBehavior.Strict, + new object[] { Mock.Of() }); + clientMock + .Setup(client => client.CompleteActivityTaskAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback(() => activityCompleted.TrySetResult()) + .Returns(CreateUnaryCall(Task.FromResult(new P.CompleteTaskResponse()))); + object processor = CreateProcessor(worker, clientMock.Object); + + // Act + InvokeDispatchWorkItem(processor, activityWorkItem, CancellationToken.None); + await activityCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Assert + clientMock.VerifyAll(); + logProvider.TryGetLogs(Category, out IReadOnlyCollection? logs).Should().BeTrue(); + logs!.Should().Contain(log => log.Message.Contains("Activity notification callback failed for phase 'Started'")); + } + [Fact] public async Task ProcessorExecuteAsync_HelloDeadlineExceeded_ReturnsChannelRecreateRequested() { From b1dc95704253affcb8bf9620e50c044b7782c5d1 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 11 Jun 2026 17:11:31 -0700 Subject: [PATCH 68/81] Sync sandbox protobuf rename --- .../SandboxActivitiesClient.cs | 6 +- ...vitiesClientServiceCollectionExtensions.cs | 6 +- .../SandboxActivityDeclarationBuilder.cs | 14 +- ...on_demand_sandbox_activities_service.proto | 93 ------------- src/Grpc/orchestrator_service.proto | 6 +- src/Grpc/refresh-protos.ps1 | 2 +- src/Grpc/sandbox_service.proto | 94 ++++++++++++++ src/Grpc/versions.txt | 5 +- .../SandboxActivitiesGrpcTransport.cs | 54 ++++---- ...bleTaskSchedulerSandboxWorkerExtensions.cs | 10 +- src/Worker/AzureManaged.Sandboxes/Logs.cs | 22 ++-- ...ActivityWorkerRegistrationHostedService.cs | 34 ++--- .../SandboxWorkerMessageBuilder.cs | 26 ++-- .../SandboxActivitiesClientTests.cs | 28 ++-- .../SandboxActivitiesTests.cs | 122 +++++++++--------- 15 files changed, 263 insertions(+), 259 deletions(-) delete mode 100644 src/Grpc/on_demand_sandbox_activities_service.proto create mode 100644 src/Grpc/sandbox_service.proto diff --git a/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClient.cs b/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClient.cs index 9291f538..34b809de 100644 --- a/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClient.cs +++ b/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClient.cs @@ -2,7 +2,7 @@ // Licensed under the MIT License. using Microsoft.DurableTask.AzureManaged.Internal; -using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; +using Proto = Microsoft.DurableTask.Protobuf.Sandboxes; namespace Microsoft.DurableTask.Client.AzureManaged; @@ -49,9 +49,9 @@ public async Task EnableSandboxActivitiesAsync(CancellationToken cancellation = continue; } - Proto.OnDemandSandboxActivityDeclaration declaration = + Proto.SandboxActivityDeclaration declaration = SandboxActivityDeclarationBuilder.BuildDeclaration(options, activityNames); - await this.transport.DeclareOnDemandSandboxActivitiesAsync( + await this.transport.DeclareSandboxActivitiesAsync( declaration, this.taskHub, cancellation) diff --git a/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs b/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs index 765cbbd6..1c725b15 100644 --- a/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs +++ b/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; -using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; +using Proto = Microsoft.DurableTask.Protobuf.Sandboxes; namespace Microsoft.DurableTask.Client.AzureManaged; @@ -53,7 +53,7 @@ public static IServiceCollection AddDurableTaskSchedulerSandboxActivitiesClient( { return new SandboxActivitiesClient( new SandboxActivitiesGrpcTransport( - new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker), + new Proto.SandboxActivities.SandboxActivitiesClient(callInvoker), attachTaskHubMetadata: false), schedulerOptions.TaskHubName, declarationProvider); @@ -63,7 +63,7 @@ public static IServiceCollection AddDurableTaskSchedulerSandboxActivitiesClient( { return new SandboxActivitiesClient( new SandboxActivitiesGrpcTransport( - new Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(channel.CreateCallInvoker()), + new Proto.SandboxActivities.SandboxActivitiesClient(channel.CreateCallInvoker()), attachTaskHubMetadata: false), schedulerOptions.TaskHubName, declarationProvider); diff --git a/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationBuilder.cs b/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationBuilder.cs index f0e4dba6..bc6c0759 100644 --- a/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationBuilder.cs +++ b/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationBuilder.cs @@ -3,7 +3,7 @@ using System.Globalization; using Microsoft.DurableTask.AzureManaged.Internal; -using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; +using Proto = Microsoft.DurableTask.Protobuf.Sandboxes; namespace Microsoft.DurableTask.Client.AzureManaged; @@ -28,7 +28,7 @@ public static string[] ResolveActivityNames(IEnumerable configuredNames) /// The on-demand sandbox options. /// The activity names included in the declaration. /// The declaration protocol message. - public static Proto.OnDemandSandboxActivityDeclaration BuildDeclaration( + public static Proto.SandboxActivityDeclaration BuildDeclaration( SandboxWorkerProfileOptions options, IReadOnlyCollection activityNames) { @@ -53,7 +53,7 @@ public static Proto.OnDemandSandboxActivityDeclaration BuildDeclaration( options.SchedulerManagedIdentityClientId ?? string.Empty, "On-demand sandbox activity declaration requires the managed identity client ID workers use to connect to the DTS scheduler."); - Proto.OnDemandSandboxActivityDeclaration declaration = new() + Proto.SandboxActivityDeclaration declaration = new() { WorkerProfileId = workerProfileId, Image = BuildImage(options), @@ -91,13 +91,13 @@ internal static string NormalizeRequired(string value, string errorMessage) return SandboxActivityMetadata.NormalizeRequired(value, errorMessage); } - static Proto.OnDemandSandboxActivityImage BuildImage(SandboxWorkerProfileOptions options) + static Proto.SandboxActivityImage BuildImage(SandboxWorkerProfileOptions options) { string imageRef = NormalizeRequired( options.ContainerImage ?? string.Empty, "On-demand sandbox activity image metadata requires a container image reference like 'myregistry.azurecr.io/workers/hello:1.0' or 'myregistry.azurecr.io/workers/hello@sha256:...'."); - Proto.OnDemandSandboxActivityImage image = new() + Proto.SandboxActivityImage image = new() { ImageRef = imageRef, ManagedIdentityClientId = NormalizeRequired( @@ -108,12 +108,12 @@ static Proto.OnDemandSandboxActivityImage BuildImage(SandboxWorkerProfileOptions return image; } - static Proto.OnDemandSandboxActivityResources BuildResources(SandboxWorkerProfileOptions options) + static Proto.SandboxActivityResources BuildResources(SandboxWorkerProfileOptions options) { string cpu = NormalizeCpu(options.Cpu); string memory = NormalizeMemory(options.Memory); - return new Proto.OnDemandSandboxActivityResources + return new Proto.SandboxActivityResources { Cpu = cpu, Memory = memory, diff --git a/src/Grpc/on_demand_sandbox_activities_service.proto b/src/Grpc/on_demand_sandbox_activities_service.proto deleted file mode 100644 index cce5e0c1..00000000 --- a/src/Grpc/on_demand_sandbox_activities_service.proto +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -syntax = "proto3"; - -package microsoft.durabletask.ondemandsandbox; - -option csharp_namespace = "Microsoft.DurableTask.Protobuf.OnDemandSandbox"; - -service OnDemandSandboxActivities { - // Opens a live on-demand sandbox activity worker session. The first message - // must be a start message with static worker metadata. Heartbeats carry - // dynamic state only. Closing the stream deregisters the worker. - rpc ConnectOnDemandSandboxActivityWorker(stream OnDemandSandboxActivityWorkerMessage) returns (OnDemandSandboxActivityWorkerSessionResult); - - // Declares on-demand sandbox activities before any live worker stream exists. - // This is a configuration contract and does not advertise active worker - // capacity. - rpc DeclareOnDemandSandboxActivities(OnDemandSandboxActivityDeclaration) returns (OnDemandSandboxActivityDeclarationResult); - - // Removes an on-demand sandbox activity declaration so the backend stops - // waking new sandbox workers for the specified worker profile. Existing - // workers are not terminated by this RPC. - rpc RemoveOnDemandSandboxActivityDeclaration(RemoveOnDemandSandboxActivityDeclarationRequest) returns (RemoveOnDemandSandboxActivityDeclarationResult); -} - -message OnDemandSandboxActivityWorkerMessage { - oneof message { - OnDemandSandboxActivityWorkerStart start = 1; - OnDemandSandboxActivityWorkerHeartbeat heartbeat = 2; - } -} - -message OnDemandSandboxActivityWorkerStart { - string task_hub = 1; - int32 max_activities_count = 3; - // Substrate the worker is running in. UNSPECIFIED = legacy (pre-substrate-aware) workers. - SubstrateKind substrate = 4; - // DTS-generated sandbox identifier injected as DTS_SANDBOX_ID. This is not - // the ADC provider sandbox resource id. - string dts_sandbox_identifier = 5; - string worker_profile_id = 6; - // Activity handlers registered by the worker process. DTS validates this - // matches the declaration before advertising worker capacity. - repeated string activity_names = 7; -} - -message OnDemandSandboxActivityWorkerHeartbeat { - int32 active_activities_count = 1; -} - -message OnDemandSandboxActivityWorkerSessionResult { - string message = 2; -} - -message OnDemandSandboxActivityDeclaration { - string worker_profile_id = 2; - repeated string activity_names = 3; - OnDemandSandboxActivityImage image = 4; - map environment_variables = 5; - int32 max_concurrent_activities = 6; - OnDemandSandboxActivityResources resources = 7; - repeated string entrypoint = 8; - repeated string cmd = 9; - string scheduler_managed_identity_client_id = 10; -} - -message OnDemandSandboxActivityImage { - string image_ref = 1; - string managed_identity_client_id = 2; -} - -message OnDemandSandboxActivityResources { - string cpu = 1; - string memory = 2; -} - -message OnDemandSandboxActivityDeclarationResult { -} - -message RemoveOnDemandSandboxActivityDeclarationRequest { - string worker_profile_id = 1; -} - -message RemoveOnDemandSandboxActivityDeclarationResult { -} - -// Compute substrate executing the activity worker. -enum SubstrateKind { - SUBSTRATE_KIND_UNSPECIFIED = 0; - SUBSTRATE_KIND_ACA_SESSION_POOL = 1; - SUBSTRATE_KIND_SANDBOX = 2; -} diff --git a/src/Grpc/orchestrator_service.proto b/src/Grpc/orchestrator_service.proto index 3d7c8eb4..e7e12524 100644 --- a/src/Grpc/orchestrator_service.proto +++ b/src/Grpc/orchestrator_service.proto @@ -370,12 +370,14 @@ message OrchestratorResponse { // Whether or not a history is required to complete the original OrchestratorRequest and none was provided. bool requiresHistory = 7; + /* Chunking logic has since been deprecated and fields related to it are marked as such */ + // True if this is a partial (chunked) completion. The backend must keep the work item open until the final chunk (isPartial=false). - bool isPartial = 8; + bool isPartial = 8 [deprecated=true]; // Zero-based position of the current chunk within a chunked completion sequence. // This field is omitted for non-chunked completions. - google.protobuf.Int32Value chunkIndex = 9; + google.protobuf.Int32Value chunkIndex = 9 [deprecated=true];; } message CreateInstanceRequest { diff --git a/src/Grpc/refresh-protos.ps1 b/src/Grpc/refresh-protos.ps1 index e1f2967f..a1510563 100644 --- a/src/Grpc/refresh-protos.ps1 +++ b/src/Grpc/refresh-protos.ps1 @@ -19,7 +19,7 @@ $commitId = $commitDetails.sha # These are the proto files we need to download from the durabletask-protobuf repository. $protoFileNames = @( "orchestrator_service.proto", - "on_demand_sandbox_activities_service.proto" + "sandbox_service.proto" ) # Download each proto file to the local directory using the above commit ID diff --git a/src/Grpc/sandbox_service.proto b/src/Grpc/sandbox_service.proto new file mode 100644 index 00000000..2fd9d513 --- /dev/null +++ b/src/Grpc/sandbox_service.proto @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +syntax = "proto3"; + +package microsoft.durabletask.sandboxes; + +option csharp_namespace = "Microsoft.DurableTask.Protobuf.Sandboxes"; + +service SandboxActivities { + // Opens a live sandbox activity worker session. The first message + // must be a start message with static worker metadata. Heartbeats carry + // dynamic state only. Closing the stream deregisters the worker. + rpc ConnectSandboxActivityWorker(stream SandboxActivityWorkerMessage) returns (SandboxActivityWorkerSessionResult); + + // Declares sandbox activities before any live worker stream exists. + // This is a configuration contract and does not advertise active worker + // capacity. + rpc DeclareSandboxActivities(SandboxActivityDeclaration) returns (SandboxActivityDeclarationResult); + + // Removes a sandbox activity declaration so the backend stops + // waking new sandbox workers for the specified worker profile. Existing + // workers are not terminated by this RPC. + rpc RemoveSandboxActivityDeclaration(RemoveSandboxActivityDeclarationRequest) returns (RemoveSandboxActivityDeclarationResult); +} + +message SandboxActivityWorkerMessage { + oneof message { + SandboxActivityWorkerStart start = 1; + SandboxActivityWorkerHeartbeat heartbeat = 2; + } +} + +message SandboxActivityWorkerStart { + string task_hub = 1; + int32 max_activities_count = 3; + // Sandbox provider the worker is running in. UNSPECIFIED = legacy + // (pre-provider-aware) workers. + SandboxProviderKind sandbox_provider = 4; + // DTS-generated sandbox identifier injected as DTS_SANDBOX_ID. This is not + // the ADC provider sandbox resource id. + string dts_sandbox_identifier = 5; + string worker_profile_id = 6; + // Activity handlers registered by the worker process. DTS validates this + // matches the declaration before advertising worker capacity. + repeated string activity_names = 7; +} + +message SandboxActivityWorkerHeartbeat { + int32 active_activities_count = 1; +} + +message SandboxActivityWorkerSessionResult { + string message = 2; +} + +message SandboxActivityDeclaration { + string worker_profile_id = 2; + repeated string activity_names = 3; + SandboxActivityImage image = 4; + map environment_variables = 5; + int32 max_concurrent_activities = 6; + SandboxActivityResources resources = 7; + repeated string entrypoint = 8; + repeated string cmd = 9; + string scheduler_managed_identity_client_id = 10; +} + +message SandboxActivityImage { + string image_ref = 1; + string managed_identity_client_id = 2; +} + +message SandboxActivityResources { + string cpu = 1; + string memory = 2; +} + +message SandboxActivityDeclarationResult { +} + +message RemoveSandboxActivityDeclarationRequest { + string worker_profile_id = 1; +} + +message RemoveSandboxActivityDeclarationResult { +} + +// Sandbox provider executing the activity worker. +enum SandboxProviderKind { + SANDBOX_PROVIDER_KIND_UNSPECIFIED = 0; + SANDBOX_PROVIDER_KIND_ACA_SESSION_POOL = 1; + SANDBOX_PROVIDER_KIND_SANDBOX = 2; +} diff --git a/src/Grpc/versions.txt b/src/Grpc/versions.txt index b781a390..2c8f46cb 100644 --- a/src/Grpc/versions.txt +++ b/src/Grpc/versions.txt @@ -1,2 +1,3 @@ -# The following files were downloaded from branch main at 2026-04-06 16:10:08 UTC -https://raw.githubusercontent.com/microsoft/durabletask-protobuf/bcf5af6a22caa70601bfc909918ba5937484279f/protos/orchestrator_service.proto +# The following files were downloaded from branch wangbill/on-demand-sandbox-protobuf at 2026-06-12 00:07:59 UTC +https://raw.githubusercontent.com/microsoft/durabletask-protobuf/67da3dbdb4a567c8892c7133246706358a53e1e8/protos/orchestrator_service.proto +https://raw.githubusercontent.com/microsoft/durabletask-protobuf/67da3dbdb4a567c8892c7133246706358a53e1e8/protos/sandbox_service.proto diff --git a/src/Shared/AzureManaged.Sandboxes/SandboxActivitiesGrpcTransport.cs b/src/Shared/AzureManaged.Sandboxes/SandboxActivitiesGrpcTransport.cs index d75d984f..d71f8165 100644 --- a/src/Shared/AzureManaged.Sandboxes/SandboxActivitiesGrpcTransport.cs +++ b/src/Shared/AzureManaged.Sandboxes/SandboxActivitiesGrpcTransport.cs @@ -2,7 +2,7 @@ // Licensed under the MIT License. using Grpc.Core; -using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; +using Proto = Microsoft.DurableTask.Protobuf.Sandboxes; namespace Microsoft.DurableTask.AzureManaged.Internal; @@ -18,8 +18,8 @@ interface ISandboxActivitiesTransport /// The task hub that owns the declaration. /// The cancellation token. /// The declaration result. - Task DeclareOnDemandSandboxActivitiesAsync( - Proto.OnDemandSandboxActivityDeclaration declaration, + Task DeclareSandboxActivitiesAsync( + Proto.SandboxActivityDeclaration declaration, string taskHub, CancellationToken cancellationToken); @@ -30,7 +30,7 @@ interface ISandboxActivitiesTransport /// The task hub that owns the declaration. /// The cancellation token. /// The removal result. - Task RemoveSandboxActivityDeclarationAsync( + Task RemoveSandboxActivityDeclarationAsync( string workerProfileId, string taskHub, CancellationToken cancellationToken); @@ -41,7 +41,7 @@ interface ISandboxActivitiesTransport /// The task hub that owns the worker session. /// The cancellation token. /// The worker registration session. - ISandboxActivityWorkerSession OpenOnDemandSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken); + ISandboxActivityWorkerSession OpenSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken); } /// @@ -54,13 +54,13 @@ interface ISandboxActivityWorkerSession : IAsyncDisposable /// /// The message to write. /// A task that completes when the message is written. - Task WriteMessageAsync(Proto.OnDemandSandboxActivityWorkerMessage message); + Task WriteMessageAsync(Proto.SandboxActivityWorkerMessage message); /// /// Waits for the server to complete the worker registration session. /// /// The worker session result. - Task WaitForCompletionAsync(); + Task WaitForCompletionAsync(); /// /// Completes the request stream and waits for the server response. @@ -74,7 +74,7 @@ interface ISandboxActivityWorkerSession : IAsyncDisposable /// sealed class SandboxActivitiesGrpcTransport : ISandboxActivitiesTransport { - readonly Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client; + readonly Proto.SandboxActivities.SandboxActivitiesClient client; readonly bool attachTaskHubMetadata; /// @@ -83,7 +83,7 @@ sealed class SandboxActivitiesGrpcTransport : ISandboxActivitiesTransport /// The generated on-demand sandbox activities gRPC client. /// True to add per-call task hub metadata when the underlying channel does not already do so. public SandboxActivitiesGrpcTransport( - Proto.OnDemandSandboxActivities.OnDemandSandboxActivitiesClient client, + Proto.SandboxActivities.SandboxActivitiesClient client, bool attachTaskHubMetadata = true) { this.client = Check.NotNull(client); @@ -91,13 +91,13 @@ public SandboxActivitiesGrpcTransport( } /// - public async Task DeclareOnDemandSandboxActivitiesAsync( - Proto.OnDemandSandboxActivityDeclaration declaration, + public async Task DeclareSandboxActivitiesAsync( + Proto.SandboxActivityDeclaration declaration, string taskHub, CancellationToken cancellationToken) { - using AsyncUnaryCall call = - this.client.DeclareOnDemandSandboxActivitiesAsync( + using AsyncUnaryCall call = + this.client.DeclareSandboxActivitiesAsync( declaration, headers: this.CreateTaskHubHeaders(taskHub), cancellationToken: cancellationToken); @@ -105,18 +105,18 @@ public SandboxActivitiesGrpcTransport( } /// - public async Task RemoveSandboxActivityDeclarationAsync( + public async Task RemoveSandboxActivityDeclarationAsync( string workerProfileId, string taskHub, CancellationToken cancellationToken) { - Proto.RemoveOnDemandSandboxActivityDeclarationRequest request = new() + Proto.RemoveSandboxActivityDeclarationRequest request = new() { WorkerProfileId = workerProfileId, }; - using AsyncUnaryCall call = - this.client.RemoveOnDemandSandboxActivityDeclarationAsync( + using AsyncUnaryCall call = + this.client.RemoveSandboxActivityDeclarationAsync( request, headers: this.CreateTaskHubHeaders(taskHub), cancellationToken: cancellationToken); @@ -124,13 +124,13 @@ public SandboxActivitiesGrpcTransport( } /// - public ISandboxActivityWorkerSession OpenOnDemandSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken) + public ISandboxActivityWorkerSession OpenSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken) { - AsyncClientStreamingCall call = - this.client.ConnectOnDemandSandboxActivityWorker( + AsyncClientStreamingCall call = + this.client.ConnectSandboxActivityWorker( headers: this.CreateTaskHubHeaders(taskHub), cancellationToken: cancellationToken); - return new GrpcOnDemandSandboxActivityWorkerSession(call); + return new GrpcSandboxActivityWorkerSession(call); } Metadata? CreateTaskHubHeaders(string taskHub) => this.attachTaskHubMetadata @@ -140,25 +140,25 @@ public ISandboxActivityWorkerSession OpenOnDemandSandboxActivityWorkerSession(st /// /// gRPC-backed on-demand sandbox activity worker registration session. /// - sealed class GrpcOnDemandSandboxActivityWorkerSession : ISandboxActivityWorkerSession + sealed class GrpcSandboxActivityWorkerSession : ISandboxActivityWorkerSession { - readonly AsyncClientStreamingCall call; + readonly AsyncClientStreamingCall call; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The active gRPC client-streaming call. - public GrpcOnDemandSandboxActivityWorkerSession(AsyncClientStreamingCall call) + public GrpcSandboxActivityWorkerSession(AsyncClientStreamingCall call) { this.call = call; } /// - public Task WriteMessageAsync(Proto.OnDemandSandboxActivityWorkerMessage message) => + public Task WriteMessageAsync(Proto.SandboxActivityWorkerMessage message) => this.call.RequestStream.WriteAsync(message); /// - public async Task WaitForCompletionAsync() => + public async Task WaitForCompletionAsync() => await this.call.ResponseAsync.ConfigureAwait(false); /// diff --git a/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs index 2d43b978..424be4db 100644 --- a/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs +++ b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs @@ -7,7 +7,7 @@ using Azure.Identity; using Grpc.Net.Client; using Microsoft.DurableTask.AzureManaged.Internal; -using Microsoft.DurableTask.Protobuf.OnDemandSandbox; +using Microsoft.DurableTask.Protobuf.Sandboxes; using Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; using Microsoft.DurableTask.Worker.Grpc; using Microsoft.DurableTask.Worker.Grpc.Internal; @@ -118,13 +118,13 @@ static SandboxActivitiesGrpcTransport CreateSandboxActivitiesTransport(IServiceP GrpcDurableTaskWorkerOptions options = services.GetRequiredService>().Get(builderName); if (options.CallInvoker is { } callInvoker) { - return new SandboxActivitiesGrpcTransport(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)); + return new SandboxActivitiesGrpcTransport(new SandboxActivities.SandboxActivitiesClient(callInvoker)); } if (options.Channel is { } channel) { return new SandboxActivitiesGrpcTransport( - new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(channel.CreateCallInvoker()), + new SandboxActivities.SandboxActivitiesClient(channel.CreateCallInvoker()), attachTaskHubMetadata: false); } @@ -173,7 +173,7 @@ static string GetRequiredEnvironmentVariable(string name) static void ApplyWorkerEnvironmentOverrides(SandboxWorkerRuntimeOptions options) { - ValidateSandboxWorkerSubstrate(GetRequiredEnvironmentVariable("DTS_SUBSTRATE")); + ValidateSandboxWorkerSandboxProvider(GetRequiredEnvironmentVariable("DTS_SUBSTRATE")); string? workerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID"); if (!string.IsNullOrWhiteSpace(workerProfileId)) @@ -187,7 +187,7 @@ static void ApplyWorkerEnvironmentOverrides(SandboxWorkerRuntimeOptions options) } } - static void ValidateSandboxWorkerSubstrate(string substrate) + static void ValidateSandboxWorkerSandboxProvider(string substrate) { if (!string.Equals(substrate, "Sandbox", StringComparison.OrdinalIgnoreCase) && !string.Equals(substrate, "AcaSessionPool", StringComparison.OrdinalIgnoreCase)) diff --git a/src/Worker/AzureManaged.Sandboxes/Logs.cs b/src/Worker/AzureManaged.Sandboxes/Logs.cs index ca0204a1..3c75f1f8 100644 --- a/src/Worker/AzureManaged.Sandboxes/Logs.cs +++ b/src/Worker/AzureManaged.Sandboxes/Logs.cs @@ -2,7 +2,7 @@ // Licensed under the MIT License. using Microsoft.Extensions.Logging; -using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; +using Proto = Microsoft.DurableTask.Protobuf.Sandboxes; namespace Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; @@ -14,49 +14,49 @@ static partial class Logs [LoggerMessage( EventId = 701, Level = LogLevel.Information, - Message = "On-demand sandbox activity worker registered hub={Hub} count={Count} substrate={Substrate} sandboxId={SandboxId}")] - public static partial void OnDemandSandboxActivityWorkerRegistered( - ILogger logger, string hub, int count, Proto.SubstrateKind substrate, string sandboxId); + Message = "On-demand sandbox activity worker registered hub={Hub} count={Count} sandboxProvider={SandboxProvider} sandboxId={SandboxId}")] + public static partial void SandboxActivityWorkerRegistered( + ILogger logger, string hub, int count, Proto.SandboxProviderKind sandboxProvider, string sandboxId); [LoggerMessage( EventId = 702, Level = LogLevel.Error, Message = "On-demand sandbox activity worker registration stream failed hub={Hub}")] - public static partial void OnDemandSandboxActivityWorkerRegistrationFailed(ILogger logger, Exception exception, string hub); + public static partial void SandboxActivityWorkerRegistrationFailed(ILogger logger, Exception exception, string hub); [LoggerMessage( EventId = 703, Level = LogLevel.Debug, Message = "Ignoring on-demand sandbox worker session completion failure during shutdown.")] - public static partial void OnDemandSandboxWorkerSessionCompletionFailureIgnored(ILogger logger, Exception exception); + public static partial void SandboxWorkerSessionCompletionFailureIgnored(ILogger logger, Exception exception); [LoggerMessage( EventId = 704, Level = LogLevel.Debug, Message = "Ignoring on-demand sandbox worker registration pump cancellation during shutdown.")] - public static partial void OnDemandSandboxWorkerRegistrationPumpCancellationIgnored(ILogger logger, Exception exception); + public static partial void SandboxWorkerRegistrationPumpCancellationIgnored(ILogger logger, Exception exception); [LoggerMessage( EventId = 705, Level = LogLevel.Debug, Message = "Ignoring on-demand sandbox worker registration pump failure during shutdown.")] - public static partial void OnDemandSandboxWorkerRegistrationPumpFailureIgnored(ILogger logger, Exception exception); + public static partial void SandboxWorkerRegistrationPumpFailureIgnored(ILogger logger, Exception exception); [LoggerMessage( EventId = 706, Level = LogLevel.Debug, Message = "Ignoring on-demand sandbox worker session dispose failure during shutdown.")] - public static partial void OnDemandSandboxWorkerSessionDisposeFailureIgnored(ILogger logger, Exception exception); + public static partial void SandboxWorkerSessionDisposeFailureIgnored(ILogger logger, Exception exception); [LoggerMessage( EventId = 707, Level = LogLevel.Debug, Message = "Ignoring on-demand sandbox heartbeat pump cancellation after registration session completion.")] - public static partial void OnDemandSandboxHeartbeatPumpCancellationIgnored(ILogger logger, Exception exception); + public static partial void SandboxHeartbeatPumpCancellationIgnored(ILogger logger, Exception exception); [LoggerMessage( EventId = 708, Level = LogLevel.Debug, Message = "Ignoring on-demand sandbox heartbeat pump failure after registration session completion.")] - public static partial void OnDemandSandboxHeartbeatPumpFailureIgnored(ILogger logger, Exception exception); + public static partial void SandboxHeartbeatPumpFailureIgnored(ILogger logger, Exception exception); } diff --git a/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs b/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs index d1cdf735..2cabf2ea 100644 --- a/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs +++ b/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs @@ -6,7 +6,7 @@ using Microsoft.DurableTask.AzureManaged.Internal; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; +using Proto = Microsoft.DurableTask.Protobuf.Sandboxes; namespace Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; @@ -96,7 +96,7 @@ public async Task StopAsync(CancellationToken cancellationToken) } catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or RpcException) { - Logs.OnDemandSandboxWorkerSessionCompletionFailureIgnored(this.logger, ex); + Logs.SandboxWorkerSessionCompletionFailureIgnored(this.logger, ex); } } @@ -108,11 +108,11 @@ public async Task StopAsync(CancellationToken cancellationToken) } catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) { - Logs.OnDemandSandboxWorkerRegistrationPumpCancellationIgnored(this.logger, ex); + Logs.SandboxWorkerRegistrationPumpCancellationIgnored(this.logger, ex); } catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or RpcException) { - Logs.OnDemandSandboxWorkerRegistrationPumpFailureIgnored(this.logger, ex); + Logs.SandboxWorkerRegistrationPumpFailureIgnored(this.logger, ex); } } @@ -168,7 +168,7 @@ static async ValueTask DisposeSessionAsync( } catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or RpcException) { - Logs.OnDemandSandboxWorkerSessionDisposeFailureIgnored(logger, ex); + Logs.SandboxWorkerSessionDisposeFailureIgnored(logger, ex); } } @@ -190,16 +190,16 @@ async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancell ISandboxActivityWorkerSession? registrationSession = null; try { - registrationSession = this.transport.OpenOnDemandSandboxActivityWorkerSession(this.options.TaskHub, cancellationToken); + registrationSession = this.transport.OpenSandboxActivityWorkerSession(this.options.TaskHub, cancellationToken); this.SetCurrentSession(registrationSession); - Proto.OnDemandSandboxActivityWorkerMessage startMessage = SandboxWorkerMessageBuilder.BuildWorkerStart(this.options, this.registeredActivityNames); + Proto.SandboxActivityWorkerMessage startMessage = SandboxWorkerMessageBuilder.BuildWorkerStart(this.options, this.registeredActivityNames); await this.WriteSessionMessageAsync(registrationSession, startMessage, cancellationToken).ConfigureAwait(false); - Logs.OnDemandSandboxActivityWorkerRegistered( + Logs.SandboxActivityWorkerRegistered( this.logger, startMessage.Start.TaskHub, activityCount, - startMessage.Start.Substrate, + startMessage.Start.SandboxProvider, startMessage.Start.DtsSandboxIdentifier); retryDelay = this.GetInitialRetryDelay(); @@ -239,7 +239,7 @@ async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancell } catch (Exception ex) when (!IsFatalException(ex)) { - Logs.OnDemandSandboxActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); + Logs.SandboxActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); this.lifetime?.StopApplication(); break; } @@ -259,7 +259,7 @@ async Task HandleRetriableRegistrationFailureAsync( TimeSpan retryDelay, CancellationToken cancellationToken) { - Logs.OnDemandSandboxActivityWorkerRegistrationFailed(this.logger, exception, this.options.TaskHub); + Logs.SandboxActivityWorkerRegistrationFailed(this.logger, exception, this.options.TaskHub); await this.DelayBeforeReconnectAsync(retryDelay, cancellationToken).ConfigureAwait(false); return this.GetNextRetryDelay(retryDelay); } @@ -270,7 +270,7 @@ async Task RunRegistrationSessionAsync( { using CancellationTokenSource heartbeatCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); Task heartbeatTask = this.PumpHeartbeatsAsync(registrationSession, heartbeatCts.Token); - Task completionTask = registrationSession.WaitForCompletionAsync(); + Task completionTask = registrationSession.WaitForCompletionAsync(); Task completedTask = await Task.WhenAny(heartbeatTask, completionTask).ConfigureAwait(false); if (ReferenceEquals(completedTask, completionTask)) @@ -282,22 +282,22 @@ async Task RunRegistrationSessionAsync( } catch (OperationCanceledException ex) when (heartbeatCts.IsCancellationRequested) { - Logs.OnDemandSandboxHeartbeatPumpCancellationIgnored(this.logger, ex); + Logs.SandboxHeartbeatPumpCancellationIgnored(this.logger, ex); } catch (RpcException ex) { // The server response is authoritative once the response task wins the race. - Logs.OnDemandSandboxHeartbeatPumpFailureIgnored(this.logger, ex); + Logs.SandboxHeartbeatPumpFailureIgnored(this.logger, ex); } catch (IOException ex) { // The server response is authoritative once the response task wins the race. - Logs.OnDemandSandboxHeartbeatPumpFailureIgnored(this.logger, ex); + Logs.SandboxHeartbeatPumpFailureIgnored(this.logger, ex); } catch (ObjectDisposedException ex) { // The server response is authoritative once the response task wins the race. - Logs.OnDemandSandboxHeartbeatPumpFailureIgnored(this.logger, ex); + Logs.SandboxHeartbeatPumpFailureIgnored(this.logger, ex); } await completionTask.ConfigureAwait(false); @@ -324,7 +324,7 @@ await this.WriteSessionMessageAsync( async Task WriteSessionMessageAsync( ISandboxActivityWorkerSession registrationSession, - Proto.OnDemandSandboxActivityWorkerMessage message, + Proto.SandboxActivityWorkerMessage message, CancellationToken cancellationToken) { await this.streamSync.WaitAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/Worker/AzureManaged.Sandboxes/SandboxWorkerMessageBuilder.cs b/src/Worker/AzureManaged.Sandboxes/SandboxWorkerMessageBuilder.cs index deb69a1e..816016e1 100644 --- a/src/Worker/AzureManaged.Sandboxes/SandboxWorkerMessageBuilder.cs +++ b/src/Worker/AzureManaged.Sandboxes/SandboxWorkerMessageBuilder.cs @@ -2,7 +2,7 @@ // Licensed under the MIT License. using Microsoft.DurableTask.AzureManaged.Internal; -using Proto = Microsoft.DurableTask.Protobuf.OnDemandSandbox; +using Proto = Microsoft.DurableTask.Protobuf.Sandboxes; namespace Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; @@ -17,7 +17,7 @@ static class SandboxWorkerMessageBuilder /// The on-demand sandbox options. /// The activity handlers registered by the worker process. /// The worker start protocol message. - public static Proto.OnDemandSandboxActivityWorkerMessage BuildWorkerStart( + public static Proto.SandboxActivityWorkerMessage BuildWorkerStart( SandboxWorkerRuntimeOptions options, IReadOnlyCollection registeredActivityNames) { @@ -45,17 +45,17 @@ public static Proto.OnDemandSandboxActivityWorkerMessage BuildWorkerStart( Environment.GetEnvironmentVariable("DTS_SANDBOX_ID") ?? string.Empty, "On-demand sandbox activity worker registration requires a DTS sandbox ID."); - Proto.OnDemandSandboxActivityWorkerStart start = new() + Proto.SandboxActivityWorkerStart start = new() { TaskHub = taskHub, WorkerProfileId = workerProfileId, MaxActivitiesCount = options.MaxConcurrentActivities, - Substrate = GetSubstrateFromEnvironment(), + SandboxProvider = GetSandboxProviderFromEnvironment(), DtsSandboxIdentifier = dtsSandboxIdentifier, }; start.ActivityNames.AddRange(activityNames); - return new Proto.OnDemandSandboxActivityWorkerMessage { Start = start }; + return new Proto.SandboxActivityWorkerMessage { Start = start }; } /// @@ -63,40 +63,40 @@ public static Proto.OnDemandSandboxActivityWorkerMessage BuildWorkerStart( /// /// The number of activities currently executing. /// The heartbeat protocol message. - public static Proto.OnDemandSandboxActivityWorkerMessage BuildWorkerHeartbeat(int activeActivitiesCount) + public static Proto.SandboxActivityWorkerMessage BuildWorkerHeartbeat(int activeActivitiesCount) { if (activeActivitiesCount < 0) { throw new InvalidOperationException("On-demand sandbox activity worker active activity count cannot be negative."); } - return new Proto.OnDemandSandboxActivityWorkerMessage + return new Proto.SandboxActivityWorkerMessage { - Heartbeat = new Proto.OnDemandSandboxActivityWorkerHeartbeat + Heartbeat = new Proto.SandboxActivityWorkerHeartbeat { ActiveActivitiesCount = activeActivitiesCount, }, }; } - static Proto.SubstrateKind GetSubstrateFromEnvironment() + static Proto.SandboxProviderKind GetSandboxProviderFromEnvironment() { string? substrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); if (substrate is null) { - return Proto.SubstrateKind.Unspecified; + return Proto.SandboxProviderKind.Unspecified; } if (substrate.Equals("Sandbox", StringComparison.OrdinalIgnoreCase)) { - return Proto.SubstrateKind.Sandbox; + return Proto.SandboxProviderKind.Sandbox; } if (substrate.Equals("AcaSessionPool", StringComparison.OrdinalIgnoreCase)) { - return Proto.SubstrateKind.AcaSessionPool; + return Proto.SandboxProviderKind.AcaSessionPool; } - return Proto.SubstrateKind.Unspecified; + return Proto.SandboxProviderKind.Unspecified; } } diff --git a/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs index 7ae71c66..6a042b0b 100644 --- a/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs +++ b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs @@ -6,7 +6,7 @@ using Grpc.Core; using Microsoft.DurableTask.AzureManaged.Internal; using Microsoft.DurableTask.Client.Grpc; -using Microsoft.DurableTask.Protobuf.OnDemandSandbox; +using Microsoft.DurableTask.Protobuf.Sandboxes; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Xunit; @@ -36,7 +36,7 @@ public void OnDemandSandboxDeclarationContract_DoesNotExposeRemovedOptions() typeof(SandboxWorkerProfileOptions).GetProperty( "Mode", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); - typeof(OnDemandSandboxActivityDeclaration).GetProperty("LaunchCommand").Should().BeNull(); + typeof(SandboxActivityDeclaration).GetProperty("LaunchCommand").Should().BeNull(); } [Fact] @@ -69,7 +69,7 @@ public void SandboxActivityDeclarationBuilder_BuildDeclaration_AcceptsAdcResourc options.Memory = memory; // Act - OnDemandSandboxActivityDeclaration declaration = SandboxActivityDeclarationBuilder.BuildDeclaration( + SandboxActivityDeclaration declaration = SandboxActivityDeclarationBuilder.BuildDeclaration( options, SandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); @@ -153,7 +153,7 @@ public void SandboxActivityDeclarationProvider_ResolveDeclarations_UsesWorkerPro // Act SandboxWorkerProfileOptions options = provider.ResolveDeclarations(TaskHub) .Single(options => options.WorkerProfileId == "annotated-profile"); - OnDemandSandboxActivityDeclaration declaration = SandboxActivityDeclarationBuilder.BuildDeclaration( + SandboxActivityDeclaration declaration = SandboxActivityDeclarationBuilder.BuildDeclaration( options, SandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames)); @@ -212,7 +212,7 @@ public async Task EnableSandboxActivitiesAsync_SendsWorkerProfileDeclarations() // Arrange RecordingOnDemandSandboxLogCallInvoker callInvoker = new(); SandboxActivitiesClient client = new( - new SandboxActivitiesGrpcTransport(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)), + new SandboxActivitiesGrpcTransport(new SandboxActivities.SandboxActivitiesClient(callInvoker)), "client-test-taskhub", new SandboxActivityDeclarationProvider()); @@ -220,7 +220,7 @@ public async Task EnableSandboxActivitiesAsync_SendsWorkerProfileDeclarations() await client.EnableSandboxActivitiesAsync(); // Assert - OnDemandSandboxActivityDeclaration declaration = callInvoker.DeclareRequests + SandboxActivityDeclaration declaration = callInvoker.DeclareRequests .Should() .ContainSingle(request => request.WorkerProfileId == "client-test-profile") .Subject; @@ -232,11 +232,11 @@ public async Task EnableSandboxActivitiesAsync_SendsWorkerProfileDeclarations() sealed class RecordingOnDemandSandboxLogCallInvoker : CallInvoker { - public List DeclareRequests { get; } = []; + public List DeclareRequests { get; } = []; public Metadata DeclareHeaders { get; private set; } = []; - public RemoveOnDemandSandboxActivityDeclarationRequest? RemoveRequest { get; private set; } + public RemoveSandboxActivityDeclarationRequest? RemoveRequest { get; private set; } public Metadata RemoveHeaders { get; private set; } = []; @@ -257,18 +257,18 @@ public override AsyncUnaryCall AsyncUnaryCall( CallOptions options, TRequest request) { - if (method.FullName.EndsWith("/DeclareOnDemandSandboxActivities", StringComparison.Ordinal)) + if (method.FullName.EndsWith("/DeclareSandboxActivities", StringComparison.Ordinal)) { - this.DeclareRequests.Add(((OnDemandSandboxActivityDeclaration)(object)request).Clone()); + this.DeclareRequests.Add(((SandboxActivityDeclaration)(object)request).Clone()); this.DeclareHeaders = options.Headers ?? []; - return CreateUnaryCall((TResponse)(object)new OnDemandSandboxActivityDeclarationResult()); + return CreateUnaryCall((TResponse)(object)new SandboxActivityDeclarationResult()); } - if (method.FullName.EndsWith("/RemoveOnDemandSandboxActivityDeclaration", StringComparison.Ordinal)) + if (method.FullName.EndsWith("/RemoveSandboxActivityDeclaration", StringComparison.Ordinal)) { - this.RemoveRequest = (RemoveOnDemandSandboxActivityDeclarationRequest)(object)request; + this.RemoveRequest = (RemoveSandboxActivityDeclarationRequest)(object)request; this.RemoveHeaders = options.Headers ?? []; - return CreateUnaryCall((TResponse)(object)new RemoveOnDemandSandboxActivityDeclarationResult()); + return CreateUnaryCall((TResponse)(object)new RemoveSandboxActivityDeclarationResult()); } throw new NotSupportedException(method.FullName); diff --git a/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs index 3da503d6..4ea30f1c 100644 --- a/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs +++ b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs @@ -5,7 +5,7 @@ using FluentAssertions; using Grpc.Core; using Microsoft.DurableTask.AzureManaged.Internal; -using Microsoft.DurableTask.Protobuf.OnDemandSandbox; +using Microsoft.DurableTask.Protobuf.Sandboxes; using Microsoft.DurableTask.Worker.AzureManaged; using Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; using Microsoft.Extensions.DependencyInjection; @@ -17,7 +17,7 @@ namespace Microsoft.DurableTask.Worker.AzureManaged.Tests; -public class OnDemandSandboxActivitiesTests +public class SandboxActivitiesTests { const string TaskHub = "testhub"; @@ -25,16 +25,16 @@ public class OnDemandSandboxActivitiesTests public async Task SandboxActivitiesGrpcTransport_SendsTaskHubMetadata() { // Arrange - RecordingOnDemandSandboxActivitiesCallInvoker callInvoker = new(); - SandboxActivitiesGrpcTransport transport = new(new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker)); - OnDemandSandboxActivityDeclaration declaration = new() + RecordingSandboxActivitiesCallInvoker callInvoker = new(); + SandboxActivitiesGrpcTransport transport = new(new SandboxActivities.SandboxActivitiesClient(callInvoker)); + SandboxActivityDeclaration declaration = new() { WorkerProfileId = "profile-a", - Image = new OnDemandSandboxActivityImage + Image = new SandboxActivityImage { ImageRef = "example.com/repo/worker:latest", }, - Resources = new OnDemandSandboxActivityResources + Resources = new SandboxActivityResources { Cpu = "500m", Memory = "1024Mi", @@ -44,8 +44,8 @@ public async Task SandboxActivitiesGrpcTransport_SendsTaskHubMetadata() declaration.ActivityNames.Add("RemoteHello"); // Act - await transport.DeclareOnDemandSandboxActivitiesAsync(declaration, TaskHub, CancellationToken.None); - await using ISandboxActivityWorkerSession session = transport.OpenOnDemandSandboxActivityWorkerSession( + await transport.DeclareSandboxActivitiesAsync(declaration, TaskHub, CancellationToken.None); + await using ISandboxActivityWorkerSession session = transport.OpenSandboxActivityWorkerSession( TaskHub, CancellationToken.None); @@ -58,18 +58,18 @@ public async Task SandboxActivitiesGrpcTransport_SendsTaskHubMetadata() public async Task SandboxActivitiesGrpcTransport_CanRelyOnChannelTaskHubMetadata() { // Arrange - RecordingOnDemandSandboxActivitiesCallInvoker callInvoker = new(); + RecordingSandboxActivitiesCallInvoker callInvoker = new(); SandboxActivitiesGrpcTransport transport = new( - new OnDemandSandboxActivities.OnDemandSandboxActivitiesClient(callInvoker), + new SandboxActivities.SandboxActivitiesClient(callInvoker), attachTaskHubMetadata: false); - OnDemandSandboxActivityDeclaration declaration = new() + SandboxActivityDeclaration declaration = new() { WorkerProfileId = "profile-a", - Image = new OnDemandSandboxActivityImage + Image = new SandboxActivityImage { ImageRef = "example.com/repo/worker:latest", }, - Resources = new OnDemandSandboxActivityResources + Resources = new SandboxActivityResources { Cpu = "500m", Memory = "1024Mi", @@ -79,8 +79,8 @@ public async Task SandboxActivitiesGrpcTransport_CanRelyOnChannelTaskHubMetadata declaration.ActivityNames.Add("RemoteHello"); // Act - await transport.DeclareOnDemandSandboxActivitiesAsync(declaration, TaskHub, CancellationToken.None); - await using ISandboxActivityWorkerSession session = transport.OpenOnDemandSandboxActivityWorkerSession( + await transport.DeclareSandboxActivitiesAsync(declaration, TaskHub, CancellationToken.None); + await using ISandboxActivityWorkerSession session = transport.OpenSandboxActivityWorkerSession( TaskHub, CancellationToken.None); @@ -93,7 +93,7 @@ public async Task SandboxActivitiesGrpcTransport_CanRelyOnChannelTaskHubMetadata public async Task SandboxActivityWorkerRegistrationHostedService_SendsLiveWorkerMetadataWithRegisteredActivities() { // Arrange - string? originalSubstrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); + string? originalSandboxProvider = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); string? originalSandboxId = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID"); Environment.SetEnvironmentVariable("DTS_SUBSTRATE", "Sandbox"); Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", "sandbox-1"); @@ -107,7 +107,7 @@ public async Task SandboxActivityWorkerRegistrationHostedService_SendsLiveWorker MaxConcurrentActivities = 3, HeartbeatInterval = TimeSpan.FromDays(1), }; - FakeOnDemandSandboxActivitiesTransport client = new(); + FakeSandboxActivitiesTransport client = new(); SandboxActivityWorkerRegistrationHostedService service = new( client, options, @@ -121,18 +121,18 @@ public async Task SandboxActivityWorkerRegistrationHostedService_SendsLiveWorker // Assert client.SessionTaskHubs.Should().Equal(TaskHub); - OnDemandSandboxActivityWorkerMessage message = client.Session.Messages.Should().ContainSingle().Subject; - OnDemandSandboxActivityWorkerStart start = message.Start; + SandboxActivityWorkerMessage message = client.Session.Messages.Should().ContainSingle().Subject; + SandboxActivityWorkerStart start = message.Start; start.TaskHub.Should().Be(TaskHub); start.WorkerProfileId.Should().Be("profile-a"); start.MaxActivitiesCount.Should().Be(3); - start.Substrate.Should().Be(SubstrateKind.Sandbox); + start.SandboxProvider.Should().Be(SandboxProviderKind.Sandbox); start.DtsSandboxIdentifier.Should().Be("sandbox-1"); start.ActivityNames.Should().Equal("RemoteHello"); } finally { - Environment.SetEnvironmentVariable("DTS_SUBSTRATE", originalSubstrate); + Environment.SetEnvironmentVariable("DTS_SUBSTRATE", originalSandboxProvider); Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", originalSandboxId); } } @@ -154,12 +154,12 @@ public void SandboxWorkerMessageBuilder_NormalizesTaskHubAndSandboxId() }; // Act - OnDemandSandboxActivityWorkerMessage message = SandboxWorkerMessageBuilder.BuildWorkerStart( + SandboxActivityWorkerMessage message = SandboxWorkerMessageBuilder.BuildWorkerStart( options, ["RemoteHello"]); // Assert - OnDemandSandboxActivityWorkerStart start = message.Start; + SandboxActivityWorkerStart start = message.Start; start.TaskHub.Should().Be(TaskHub); start.DtsSandboxIdentifier.Should().Be("sandbox-1"); } @@ -243,7 +243,7 @@ public async Task SandboxActivityWorkerRegistrationHostedService_SendsHeartbeatW HeartbeatInterval = TimeSpan.FromMilliseconds(10), }; - FakeOnDemandSandboxActivitiesTransport client = new(); + FakeSandboxActivitiesTransport client = new(); SandboxActivityTracker activityTracker = new(); activityTracker.NotifyActivityStarted(); activityTracker.NotifyActivityStarted(); @@ -282,9 +282,9 @@ public async Task SandboxActivityWorkerRegistrationHostedService_ReopensSessionA WorkerRegistrationRetryMaxDelay = TimeSpan.FromMilliseconds(10), }; - FakeOnDemandSandboxActivityWorkerSession failedSession = new() { ThrowOnWriteAttempt = 2 }; - FakeOnDemandSandboxActivityWorkerSession recoveredSession = new(); - FakeOnDemandSandboxActivitiesTransport client = new(); + FakeSandboxActivityWorkerSession failedSession = new() { ThrowOnWriteAttempt = 2 }; + FakeSandboxActivityWorkerSession recoveredSession = new(); + FakeSandboxActivitiesTransport client = new(); client.QueueSession(failedSession); client.QueueSession(recoveredSession); @@ -321,9 +321,9 @@ public async Task SandboxActivityWorkerRegistrationHostedService_ReopensSessionA WorkerRegistrationRetryMaxDelay = TimeSpan.FromMilliseconds(10), }; - FakeOnDemandSandboxActivityWorkerSession failedSession = new(); - FakeOnDemandSandboxActivityWorkerSession recoveredSession = new(); - FakeOnDemandSandboxActivitiesTransport client = new(); + FakeSandboxActivityWorkerSession failedSession = new(); + FakeSandboxActivityWorkerSession recoveredSession = new(); + FakeSandboxActivitiesTransport client = new(); client.QueueSession(failedSession); client.QueueSession(recoveredSession); @@ -389,9 +389,9 @@ public async Task SandboxActivityWorkerRegistrationHostedService_AppliesJitterTo WorkerRegistrationRetryMaxDelay = TimeSpan.FromDays(1), }; - FakeOnDemandSandboxActivityWorkerSession failedSession = new() { ThrowOnWriteAttempt = 2 }; - FakeOnDemandSandboxActivityWorkerSession recoveredSession = new(); - FakeOnDemandSandboxActivitiesTransport client = new(); + FakeSandboxActivityWorkerSession failedSession = new() { ThrowOnWriteAttempt = 2 }; + FakeSandboxActivityWorkerSession recoveredSession = new(); + FakeSandboxActivitiesTransport client = new(); client.QueueSession(failedSession); client.QueueSession(recoveredSession); @@ -425,8 +425,8 @@ public async Task SandboxActivityWorkerRegistrationHostedService_StopAsync_DoesN HeartbeatInterval = TimeSpan.FromMilliseconds(10), }; - FakeOnDemandSandboxActivityWorkerSession session = new() { BlockWriteAttempt = 2 }; - FakeOnDemandSandboxActivitiesTransport client = new(); + FakeSandboxActivityWorkerSession session = new() { BlockWriteAttempt = 2 }; + FakeSandboxActivitiesTransport client = new(); client.QueueSession(session); SandboxActivityWorkerRegistrationHostedService service = new( @@ -646,7 +646,7 @@ public void UseSandboxWorker_MissingInjectedTaskHub_Throws() } [Fact] - public async Task UseSandboxWorker_MissingInjectedSubstrate_ThrowsWhenWorkerOptionsAreResolved() + public async Task UseSandboxWorker_MissingInjectedSandboxProvider_ThrowsWhenWorkerOptionsAreResolved() { // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); @@ -674,7 +674,7 @@ public async Task UseSandboxWorker_MissingInjectedSubstrate_ThrowsWhenWorkerOpti } [Fact] - public async Task UseSandboxWorker_InvalidInjectedSubstrate_ThrowsWhenWorkerOptionsAreResolved() + public async Task UseSandboxWorker_InvalidInjectedSandboxProvider_ThrowsWhenWorkerOptionsAreResolved() { // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); @@ -701,27 +701,27 @@ public async Task UseSandboxWorker_InvalidInjectedSubstrate_ThrowsWhenWorkerOpti .WithMessage("DTS_SUBSTRATE must be 'Sandbox' or 'AcaSessionPool' for on-demand sandbox workers."); } - sealed class FakeOnDemandSandboxActivitiesTransport : ISandboxActivitiesTransport + sealed class FakeSandboxActivitiesTransport : ISandboxActivitiesTransport { - readonly Queue queuedSessions = new(); + readonly Queue queuedSessions = new(); public List SessionTaskHubs { get; } = []; - public List Sessions { get; } = []; + public List Sessions { get; } = []; - public FakeOnDemandSandboxActivityWorkerSession Session { get; } = new(); + public FakeSandboxActivityWorkerSession Session { get; } = new(); - public void QueueSession(FakeOnDemandSandboxActivityWorkerSession session) => this.queuedSessions.Enqueue(session); + public void QueueSession(FakeSandboxActivityWorkerSession session) => this.queuedSessions.Enqueue(session); - public Task DeclareOnDemandSandboxActivitiesAsync( - OnDemandSandboxActivityDeclaration declaration, + public Task DeclareSandboxActivitiesAsync( + SandboxActivityDeclaration declaration, string taskHub, CancellationToken cancellationToken) { throw new NotSupportedException(); } - public Task RemoveSandboxActivityDeclarationAsync( + public Task RemoveSandboxActivityDeclarationAsync( string workerProfileId, string taskHub, CancellationToken cancellationToken) @@ -729,10 +729,10 @@ public Task RemoveSandboxActivit throw new NotSupportedException(); } - public ISandboxActivityWorkerSession OpenOnDemandSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken) + public ISandboxActivityWorkerSession OpenSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken) { this.SessionTaskHubs.Add(taskHub); - FakeOnDemandSandboxActivityWorkerSession session = this.queuedSessions.Count > 0 + FakeSandboxActivityWorkerSession session = this.queuedSessions.Count > 0 ? this.queuedSessions.Dequeue() : this.Session; this.Sessions.Add(session); @@ -740,7 +740,7 @@ public ISandboxActivityWorkerSession OpenOnDemandSandboxActivityWorkerSession(st } } - sealed class RecordingOnDemandSandboxActivitiesCallInvoker : CallInvoker + sealed class RecordingSandboxActivitiesCallInvoker : CallInvoker { public Metadata DeclarationHeaders { get; private set; } = []; @@ -761,11 +761,11 @@ public override AsyncUnaryCall AsyncUnaryCall( CallOptions options, TRequest request) { - method.FullName.Should().EndWith("/DeclareOnDemandSandboxActivities"); + method.FullName.Should().EndWith("/DeclareSandboxActivities"); this.DeclarationHeaders = options.Headers ?? []; return new AsyncUnaryCall( - Task.FromResult((TResponse)(object)new OnDemandSandboxActivityDeclarationResult()), + Task.FromResult((TResponse)(object)new SandboxActivityDeclarationResult()), Task.FromResult(new Metadata()), () => new Status(StatusCode.OK, string.Empty), () => [], @@ -786,12 +786,12 @@ public override AsyncClientStreamingCall AsyncClientStreami string? host, CallOptions options) { - method.FullName.Should().EndWith("/ConnectOnDemandSandboxActivityWorker"); + method.FullName.Should().EndWith("/ConnectSandboxActivityWorker"); this.WorkerSessionHeaders = options.Headers ?? []; return new AsyncClientStreamingCall( new RecordingClientStreamWriter(), - Task.FromResult((TResponse)(object)new OnDemandSandboxActivityWorkerSessionResult()), + Task.FromResult((TResponse)(object)new SandboxActivityWorkerSessionResult()), Task.FromResult(new Metadata()), () => new Status(StatusCode.OK, string.Empty), () => [], @@ -816,10 +816,10 @@ sealed class RecordingClientStreamWriter : IClientStreamWriter public Task CompleteAsync() => Task.CompletedTask; } - sealed class FakeOnDemandSandboxActivityWorkerSession : ISandboxActivityWorkerSession + sealed class FakeSandboxActivityWorkerSession : ISandboxActivityWorkerSession { readonly object sync = new(); - readonly TaskCompletionSource completion = + readonly TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously); readonly TaskCompletionSource blockedWriteStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -828,7 +828,7 @@ sealed class FakeOnDemandSandboxActivityWorkerSession : ISandboxActivityWorkerSe int writeAttempts; int activeWrites; - public List Messages { get; } = []; + public List Messages { get; } = []; public int? ThrowOnWriteAttempt { get; init; } @@ -852,7 +852,7 @@ public Task WaitForCompleteAsync() public void ReleaseBlockedWrite() => this.releaseBlockedWrite.TrySetResult(); - public async Task WaitForMessageAsync(Func predicate) + public async Task WaitForMessageAsync(Func predicate) { using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5)); while (!timeout.IsCancellationRequested) @@ -871,7 +871,7 @@ public async Task WaitForMessageAsync(Func WaitForCompletionAsync() => this.completion.Task; + public Task WaitForCompletionAsync() => this.completion.Task; public async Task CompleteAsync() { @@ -904,13 +904,13 @@ public async Task CompleteAsync() this.CompleteCalledWhileWriteActive = this.activeWrites > 0; } - this.completion.TrySetResult(new OnDemandSandboxActivityWorkerSessionResult()); + this.completion.TrySetResult(new SandboxActivityWorkerSessionResult()); await this.completion.Task.ConfigureAwait(false); } public ValueTask DisposeAsync() => default; - async Task WriteMessageCoreAsync(OnDemandSandboxActivityWorkerMessage message, bool blockWrite) + async Task WriteMessageCoreAsync(SandboxActivityWorkerMessage message, bool blockWrite) { try { From 6864544a723f48e68935d54a734f87c31cd8e541 Mon Sep 17 00:00:00 2001 From: wangbill Date: Thu, 11 Jun 2026 17:14:35 -0700 Subject: [PATCH 69/81] Filter fatal notification exceptions --- src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs index a172b2ef..440edcd4 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs @@ -466,7 +466,10 @@ void NotifyActivity(ActivityNotificationPhase phase) { this.internalOptions.NotifyActivity?.Invoke(phase); } - catch (Exception ex) + catch (Exception ex) when (ex is not OutOfMemoryException + and not StackOverflowException + and not AccessViolationException + and not ThreadAbortException) { this.Logger.ActivityNotificationFailed(phase, ex); } From ce8894df2f36d4ae82b2ea9ba228c65f6e8e8ebb Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 12 Jun 2026 11:20:35 -0700 Subject: [PATCH 70/81] Address sandbox SDK review comments --- .../on-demand-sandbox/main-app/Activities.cs | 2 + .../SandboxActivityDeclarationProvider.cs | 2 +- src/Grpc/orchestrator_service.proto | 2 +- ...bleTaskSchedulerSandboxWorkerExtensions.cs | 27 +++++++--- ...ActivityWorkerRegistrationHostedService.cs | 14 ++--- .../SandboxActivitiesClientTests.cs | 43 +++++++++++++++ .../SandboxActivitiesTests.cs | 53 ++++++++++++++++++- 7 files changed, 127 insertions(+), 16 deletions(-) diff --git a/samples/on-demand-sandbox/main-app/Activities.cs b/samples/on-demand-sandbox/main-app/Activities.cs index 7f6b23bb..4048e3ea 100644 --- a/samples/on-demand-sandbox/main-app/Activities.cs +++ b/samples/on-demand-sandbox/main-app/Activities.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.DurableTask; + namespace Microsoft.DurableTask.Samples.OnDemandSandbox.MainApp; internal static class OnDemandSandboxTaskNames diff --git a/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationProvider.cs b/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationProvider.cs index 97decf55..47c3c097 100644 --- a/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationProvider.cs +++ b/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationProvider.cs @@ -128,7 +128,7 @@ static IEnumerable GetCandidateTypes() static void ValidateActivityOwnership(IEnumerable declarations) { - Dictionary activityOwners = new(StringComparer.Ordinal); + Dictionary activityOwners = new(StringComparer.OrdinalIgnoreCase); foreach (SandboxWorkerProfileOptions declaration in declarations) { foreach (string activityName in SandboxActivityDeclarationBuilder.ResolveActivityNames(declaration.ActivityNames)) diff --git a/src/Grpc/orchestrator_service.proto b/src/Grpc/orchestrator_service.proto index e7e12524..3d9194ac 100644 --- a/src/Grpc/orchestrator_service.proto +++ b/src/Grpc/orchestrator_service.proto @@ -377,7 +377,7 @@ message OrchestratorResponse { // Zero-based position of the current chunk within a chunked completion sequence. // This field is omitted for non-chunked completions. - google.protobuf.Int32Value chunkIndex = 9 [deprecated=true];; + google.protobuf.Int32Value chunkIndex = 9 [deprecated=true]; } message CreateInstanceRequest { diff --git a/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs index 424be4db..a32aa9a7 100644 --- a/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs +++ b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs @@ -27,6 +27,10 @@ public static class DurableTaskSchedulerSandboxWorkerExtensions "On-demand sandbox workers require at least one registered activity. " + "Register an activity on this worker before starting the sandbox worker."; + const string ManagedIdentityAuthentication = "ManagedIdentity"; + + const string NoAuthentication = "None"; + /// /// Configures this worker as an on-demand sandbox activity worker that connects to DTS to receive and execute /// on-demand sandbox activities. Use this on a dedicated worker binary that runs inside sandbox infrastructure. @@ -143,24 +147,35 @@ static void ConfigureDurableTaskSchedulerFromEnvironment(IDurableTaskWorkerBuild { string endpoint = GetRequiredEnvironmentVariable("DTS_ENDPOINT"); string taskHub = GetRequiredEnvironmentVariable("DTS_TASK_HUB"); + string authentication = GetRequiredEnvironmentVariable("DTS_AUTHENTICATION"); builder.UseDurableTaskScheduler(options => { options.EndpointAddress = endpoint; options.TaskHubName = taskHub; - options.AllowInsecureCredentials = true; - if (UsesManagedIdentityAuthentication(Environment.GetEnvironmentVariable("DTS_AUTHENTICATION"))) + if (UsesManagedIdentityAuthentication(authentication)) { options.Credential = CreateManagedIdentityCredential(); - options.AllowInsecureCredentials = false; + } + else if (UsesNoAuthentication(authentication)) + { + options.AllowInsecureCredentials = true; + } + else + { + throw new InvalidOperationException( + $"DTS_AUTHENTICATION must be '{ManagedIdentityAuthentication}' or '{NoAuthentication}' for on-demand sandbox workers."); } }); } - static bool UsesManagedIdentityAuthentication(string? authentication) => - string.Equals(authentication, "ManagedIdentity", StringComparison.OrdinalIgnoreCase); + static bool UsesManagedIdentityAuthentication(string authentication) => + string.Equals(authentication, ManagedIdentityAuthentication, StringComparison.OrdinalIgnoreCase); + + static bool UsesNoAuthentication(string authentication) => + string.Equals(authentication, NoAuthentication, StringComparison.OrdinalIgnoreCase); - static TokenCredential CreateManagedIdentityCredential() => + static ManagedIdentityCredential CreateManagedIdentityCredential() => new ManagedIdentityCredential(ManagedIdentityId.FromUserAssignedClientId(GetRequiredEnvironmentVariable("DTS_UMI_CLIENT_ID"))); static string GetRequiredEnvironmentVariable(string name) diff --git a/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs b/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs index 2cabf2ea..88e544d9 100644 --- a/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs +++ b/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs @@ -182,6 +182,11 @@ or StatusCode.ResourceExhausted or StatusCode.Unavailable or StatusCode.Unknown); + static bool IsFatalException(Exception ex) => ex is OutOfMemoryException + or StackOverflowException + or AccessViolationException + or ThreadAbortException; + async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancellationToken) { TimeSpan retryDelay = this.GetInitialRetryDelay(); @@ -190,10 +195,10 @@ async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancell ISandboxActivityWorkerSession? registrationSession = null; try { - registrationSession = this.transport.OpenSandboxActivityWorkerSession(this.options.TaskHub, cancellationToken); + Proto.SandboxActivityWorkerMessage startMessage = SandboxWorkerMessageBuilder.BuildWorkerStart(this.options, this.registeredActivityNames); + registrationSession = this.transport.OpenSandboxActivityWorkerSession(startMessage.Start.TaskHub, cancellationToken); this.SetCurrentSession(registrationSession); - Proto.SandboxActivityWorkerMessage startMessage = SandboxWorkerMessageBuilder.BuildWorkerStart(this.options, this.registeredActivityNames); await this.WriteSessionMessageAsync(registrationSession, startMessage, cancellationToken).ConfigureAwait(false); Logs.SandboxActivityWorkerRegistered( this.logger, @@ -397,9 +402,4 @@ async Task DelayBeforeReconnectAsync(TimeSpan retryDelay, CancellationToken canc await Task.Delay(jitteredDelay, cancellationToken).ConfigureAwait(false); } } - - static bool IsFatalException(Exception ex) => ex is OutOfMemoryException - or StackOverflowException - or AccessViolationException - or ThreadAbortException; } diff --git a/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs index 6a042b0b..4d047496 100644 --- a/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs +++ b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs @@ -183,6 +183,25 @@ public void SandboxActivityDeclarationProvider_ValidateProfileType_RequiresProfi .WithMessage($"*{nameof(ISandboxWorkerProfile)}*"); } + [Fact] + public void SandboxActivityDeclarationProvider_ResolveDeclarations_DetectsCaseInsensitiveActivityOwnership() + { + // Arrange + using EnvironmentVariableScope enableDuplicateCaseProfiles = new( + "DTS_TEST_ENABLE_CASE_DUPLICATE_SANDBOX_PROFILES", + "true"); + SandboxActivityDeclarationProvider provider = new(); + + // Act + Action action = () => provider.ResolveDeclarations(TaskHub); + + // Assert + action.Should().Throw() + .Where(ex => ex.Message.Contains("CaseActivity", StringComparison.OrdinalIgnoreCase) + && ex.Message.Contains("case-profile-a", StringComparison.Ordinal) + && ex.Message.Contains("case-profile-b", StringComparison.Ordinal)); + } + [Fact] public async Task AddDurableTaskSchedulerSandboxActivitiesClient_UsesConfiguredDurableTaskClientInvoker() { @@ -361,6 +380,30 @@ public void Configure(SandboxWorkerProfileOptions options) } } + [SandboxWorkerProfile("case-profile-a")] + sealed class CaseDuplicateWorkerProfileA : ISandboxWorkerProfile + { + public void Configure(SandboxWorkerProfileOptions options) + { + if (Environment.GetEnvironmentVariable("DTS_TEST_ENABLE_CASE_DUPLICATE_SANDBOX_PROFILES") == "true") + { + options.AddActivity("CaseActivity"); + } + } + } + + [SandboxWorkerProfile("case-profile-b")] + sealed class CaseDuplicateWorkerProfileB : ISandboxWorkerProfile + { + public void Configure(SandboxWorkerProfileOptions options) + { + if (Environment.GetEnvironmentVariable("DTS_TEST_ENABLE_CASE_DUPLICATE_SANDBOX_PROFILES") == "true") + { + options.AddActivity("caseactivity"); + } + } + } + sealed class ProfileWithoutInterface { } diff --git a/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs index 4ea30f1c..df1da021 100644 --- a/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs +++ b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs @@ -313,7 +313,7 @@ public async Task SandboxActivityWorkerRegistrationHostedService_ReopensSessionA using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); SandboxWorkerRuntimeOptions options = new() { - TaskHub = TaskHub, + TaskHub = $" {TaskHub} ", WorkerProfileId = "profile-a", MaxConcurrentActivities = 3, HeartbeatInterval = TimeSpan.FromDays(1), @@ -458,6 +458,7 @@ public async Task UseSandboxWorker_ConfiguresRegisteredActivityWorkerFilter() // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "None"); using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "Sandbox"); using EnvironmentVariableScope maxActivities = new("DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES", "3"); ServiceCollection services = new(); @@ -490,6 +491,7 @@ public async Task UseSandboxWorker_WithNoRegisteredActivities_FailsWhenWorkerFil // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "None"); using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "Sandbox"); ServiceCollection services = new(); Mock mockBuilder = new(); @@ -516,6 +518,7 @@ public async Task UseSandboxWorker_ConfiguresSchedulerWithoutCredential() // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "None"); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); @@ -536,6 +539,51 @@ public async Task UseSandboxWorker_ConfiguresSchedulerWithoutCredential() options.AllowInsecureCredentials.Should().BeTrue(); } + [Fact] + public void UseSandboxWorker_MissingAuthentication_Throws() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", null); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + // Act + Action action = () => mockBuilder.Object.UseSandboxWorker(); + + // Assert + action.Should().Throw() + .WithMessage("DTS_AUTHENTICATION must be injected by DTS for on-demand sandbox workers."); + } + + [Fact] + public async Task UseSandboxWorker_InvalidAuthentication_ThrowsWhenSchedulerOptionsAreResolved() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentty"); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + mockBuilder.Object.UseSandboxWorker(); + await using ServiceProvider provider = services.BuildServiceProvider(); + + // Act + Action action = () => provider + .GetRequiredService>() + .Get(Options.DefaultName); + + // Assert + action.Should().Throw() + .WithMessage("DTS_AUTHENTICATION must be 'ManagedIdentity' or 'None' for on-demand sandbox workers."); + } + [Fact] public async Task UseSandboxWorker_WithManagedIdentityAuth_ConfiguresSchedulerCredential() { @@ -595,6 +643,7 @@ public void UseSandboxWorker_DoesNotRegisterWakeupServerHostedService() // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "None"); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); @@ -651,6 +700,7 @@ public async Task UseSandboxWorker_MissingInjectedSandboxProvider_ThrowsWhenWork // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "None"); using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", null); ServiceCollection services = new(); services.Configure( @@ -679,6 +729,7 @@ public async Task UseSandboxWorker_InvalidInjectedSandboxProvider_ThrowsWhenWork // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "None"); using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "ContainerApp"); ServiceCollection services = new(); services.Configure( From dd0f8572e54f390137d9c01e8d906cc81d3963d0 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 12 Jun 2026 11:58:34 -0700 Subject: [PATCH 71/81] Require managed identity for sandbox workers --- ...bleTaskSchedulerSandboxWorkerExtensions.cs | 12 ++------- .../SandboxActivitiesTests.cs | 25 +++++++++++-------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs index a32aa9a7..2d1ec6bb 100644 --- a/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs +++ b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs @@ -29,8 +29,6 @@ public static class DurableTaskSchedulerSandboxWorkerExtensions const string ManagedIdentityAuthentication = "ManagedIdentity"; - const string NoAuthentication = "None"; - /// /// Configures this worker as an on-demand sandbox activity worker that connects to DTS to receive and execute /// on-demand sandbox activities. Use this on a dedicated worker binary that runs inside sandbox infrastructure. @@ -156,15 +154,12 @@ static void ConfigureDurableTaskSchedulerFromEnvironment(IDurableTaskWorkerBuild if (UsesManagedIdentityAuthentication(authentication)) { options.Credential = CreateManagedIdentityCredential(); - } - else if (UsesNoAuthentication(authentication)) - { - options.AllowInsecureCredentials = true; + options.AllowInsecureCredentials = false; } else { throw new InvalidOperationException( - $"DTS_AUTHENTICATION must be '{ManagedIdentityAuthentication}' or '{NoAuthentication}' for on-demand sandbox workers."); + $"DTS_AUTHENTICATION must be '{ManagedIdentityAuthentication}' for on-demand sandbox workers."); } }); } @@ -172,9 +167,6 @@ static void ConfigureDurableTaskSchedulerFromEnvironment(IDurableTaskWorkerBuild static bool UsesManagedIdentityAuthentication(string authentication) => string.Equals(authentication, ManagedIdentityAuthentication, StringComparison.OrdinalIgnoreCase); - static bool UsesNoAuthentication(string authentication) => - string.Equals(authentication, NoAuthentication, StringComparison.OrdinalIgnoreCase); - static ManagedIdentityCredential CreateManagedIdentityCredential() => new ManagedIdentityCredential(ManagedIdentityId.FromUserAssignedClientId(GetRequiredEnvironmentVariable("DTS_UMI_CLIENT_ID"))); diff --git a/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs index df1da021..b6b38fec 100644 --- a/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs +++ b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs @@ -458,7 +458,8 @@ public async Task UseSandboxWorker_ConfiguresRegisteredActivityWorkerFilter() // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); - using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "None"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); + using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "Sandbox"); using EnvironmentVariableScope maxActivities = new("DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES", "3"); ServiceCollection services = new(); @@ -491,7 +492,8 @@ public async Task UseSandboxWorker_WithNoRegisteredActivities_FailsWhenWorkerFil // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); - using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "None"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); + using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "Sandbox"); ServiceCollection services = new(); Mock mockBuilder = new(); @@ -513,12 +515,13 @@ public async Task UseSandboxWorker_WithNoRegisteredActivities_FailsWhenWorkerFil } [Fact] - public async Task UseSandboxWorker_ConfiguresSchedulerWithoutCredential() + public async Task UseSandboxWorker_ConfiguresSchedulerWithManagedIdentityCredential() { // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); - using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "None"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); + using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); @@ -535,8 +538,8 @@ public async Task UseSandboxWorker_ConfiguresSchedulerWithoutCredential() // Assert options.EndpointAddress.Should().Be("https://example.scheduler"); options.TaskHubName.Should().Be(TaskHub); - options.Credential.Should().BeNull(); - options.AllowInsecureCredentials.Should().BeTrue(); + options.Credential.Should().BeOfType(); + options.AllowInsecureCredentials.Should().BeFalse(); } [Fact] @@ -581,7 +584,7 @@ public async Task UseSandboxWorker_InvalidAuthentication_ThrowsWhenSchedulerOpti // Assert action.Should().Throw() - .WithMessage("DTS_AUTHENTICATION must be 'ManagedIdentity' or 'None' for on-demand sandbox workers."); + .WithMessage("DTS_AUTHENTICATION must be 'ManagedIdentity' for on-demand sandbox workers."); } [Fact] @@ -643,7 +646,7 @@ public void UseSandboxWorker_DoesNotRegisterWakeupServerHostedService() // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); - using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "None"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); @@ -700,7 +703,8 @@ public async Task UseSandboxWorker_MissingInjectedSandboxProvider_ThrowsWhenWork // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); - using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "None"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); + using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", null); ServiceCollection services = new(); services.Configure( @@ -729,7 +733,8 @@ public async Task UseSandboxWorker_InvalidInjectedSandboxProvider_ThrowsWhenWork // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); - using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "None"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); + using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "ContainerApp"); ServiceCollection services = new(); services.Configure( From 78a8318b16d57d49212dd1e144556370d41c1a86 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 12 Jun 2026 12:02:46 -0700 Subject: [PATCH 72/81] Preserve sandbox worker registration backoff --- ...ActivityWorkerRegistrationHostedService.cs | 2 +- .../SandboxActivitiesTests.cs | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs b/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs index 88e544d9..a8b11a33 100644 --- a/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs +++ b/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs @@ -207,8 +207,8 @@ async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancell startMessage.Start.SandboxProvider, startMessage.Start.DtsSandboxIdentifier); - retryDelay = this.GetInitialRetryDelay(); await this.RunRegistrationSessionAsync(registrationSession, cancellationToken).ConfigureAwait(false); + retryDelay = this.GetInitialRetryDelay(); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { diff --git a/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs index b6b38fec..0b5d74cb 100644 --- a/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs +++ b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs @@ -346,6 +346,54 @@ public async Task SandboxActivityWorkerRegistrationHostedService_ReopensSessionA recoveredSession.Messages.Should().ContainSingle(message => message.Start != null); } + [Fact] + public async Task SandboxActivityWorkerRegistrationHostedService_DoesNotResetBackoffAfterStartMessageOnly() + { + // Arrange + using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); + SandboxWorkerRuntimeOptions options = new() + { + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + HeartbeatInterval = TimeSpan.FromDays(1), + WorkerRegistrationRetryInitialDelay = TimeSpan.FromMilliseconds(250), + WorkerRegistrationRetryMaxDelay = TimeSpan.FromSeconds(1), + }; + + FakeSandboxActivityWorkerSession firstFailedSession = new(); + FakeSandboxActivityWorkerSession secondFailedSession = new(); + FakeSandboxActivityWorkerSession recoveredSession = new(); + FakeSandboxActivitiesTransport client = new(); + client.QueueSession(firstFailedSession); + client.QueueSession(secondFailedSession); + client.QueueSession(recoveredSession); + + SandboxActivityWorkerRegistrationHostedService service = new( + client, + options, + ["RemoteHello"], + NullLogger.Instance, + reconnectJitter: new DeterministicRandom(0.999999)); + + // Act + await service.StartAsync(CancellationToken.None); + await firstFailedSession.WaitForMessageAsync(message => message.Start != null); + firstFailedSession.FailCompletion(new RpcException(new Status(StatusCode.Unavailable, "terminal-1"))); + await secondFailedSession.WaitForMessageAsync(message => message.Start != null); + secondFailedSession.FailCompletion(new RpcException(new Status(StatusCode.Unavailable, "terminal-2"))); + Task recoveredStartTask = recoveredSession.WaitForMessageAsync(message => message.Start != null); + Task completedTooEarly = await Task.WhenAny( + recoveredStartTask, + Task.Delay(TimeSpan.FromMilliseconds(375))); + await recoveredStartTask.WaitAsync(TimeSpan.FromSeconds(5)); + await service.StopAsync(CancellationToken.None); + + // Assert + completedTooEarly.Should().NotBe(recoveredStartTask); + client.SessionTaskHubs.Should().Equal(TaskHub, TaskHub, TaskHub); + } + [Fact] public void SandboxActivityWorkerRegistrationHostedService_ComputeJitteredReconnectDelay_UsesFullJitterWindow() { From cafe9900b48dc897bd2b57656a178ada51ca12fc Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 12 Jun 2026 12:17:18 -0700 Subject: [PATCH 73/81] Address sandbox client metadata comments --- samples/on-demand-sandbox/remote-worker/Containerfile | 6 +----- .../SandboxActivitiesClientServiceCollectionExtensions.cs | 4 +--- .../AzureManaged.Tests/SandboxActivitiesClientTests.cs | 1 + 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/samples/on-demand-sandbox/remote-worker/Containerfile b/samples/on-demand-sandbox/remote-worker/Containerfile index 6df4b6ae..99267aa4 100644 --- a/samples/on-demand-sandbox/remote-worker/Containerfile +++ b/samples/on-demand-sandbox/remote-worker/Containerfile @@ -22,13 +22,9 @@ RUN case "$TARGETARCH" in \ /p:DebugType=None \ && find /app/publish -type f \( -name '*.xml' -o -name '*.pdb' \) -delete -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS runtime WORKDIR /app -ENV ASPNETCORE_URLS=http://+:8080 - -EXPOSE 8080 - COPY --from=build /app/publish ./ ENTRYPOINT ["dotnet", "OnDemandSandboxRemoteWorker.dll"] diff --git a/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs b/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs index 1c725b15..d6c109de 100644 --- a/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs +++ b/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs @@ -52,9 +52,7 @@ public static IServiceCollection AddDurableTaskSchedulerSandboxActivitiesClient( if (options.CallInvoker is { } callInvoker) { return new SandboxActivitiesClient( - new SandboxActivitiesGrpcTransport( - new Proto.SandboxActivities.SandboxActivitiesClient(callInvoker), - attachTaskHubMetadata: false), + new SandboxActivitiesGrpcTransport(new Proto.SandboxActivities.SandboxActivitiesClient(callInvoker)), schedulerOptions.TaskHubName, declarationProvider); } diff --git a/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs index 4d047496..2c9f8586 100644 --- a/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs +++ b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs @@ -223,6 +223,7 @@ public async Task AddDurableTaskSchedulerSandboxActivitiesClient_UsesConfiguredD // Assert callInvoker.RemoveRequest.Should().NotBeNull(); callInvoker.RemoveRequest!.WorkerProfileId.Should().Be("default"); + callInvoker.RemoveHeaders.Should().Contain(header => header.Key == "taskhub" && header.Value == "client-test-taskhub"); } [Fact] From 15c503948f6b8fb4c09377d3e6a9606bf0cd13a2 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 12 Jun 2026 12:18:23 -0700 Subject: [PATCH 74/81] Fix sandbox test timeout helper --- test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs index 0b5d74cb..0324a17c 100644 --- a/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs +++ b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs @@ -969,7 +969,7 @@ public async Task WaitForMessageAsync(Func p } } - await Task.Delay(TimeSpan.FromMilliseconds(10), timeout.Token); + await Task.Delay(TimeSpan.FromMilliseconds(10)); } throw new TimeoutException("Timed out waiting for on-demand sandbox worker message."); From 5be4dccf3e24b2acb77a1834a66edbc4415476a7 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 12 Jun 2026 12:55:02 -0700 Subject: [PATCH 75/81] Observe sandbox session completion failures --- src/Worker/AzureManaged.Sandboxes/Logs.cs | 6 +++++ ...ActivityWorkerRegistrationHostedService.cs | 23 ++++++++++++++++++- .../Internal/InternalOptionsExtensions.cs | 2 -- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/Worker/AzureManaged.Sandboxes/Logs.cs b/src/Worker/AzureManaged.Sandboxes/Logs.cs index 3c75f1f8..0a0d8153 100644 --- a/src/Worker/AzureManaged.Sandboxes/Logs.cs +++ b/src/Worker/AzureManaged.Sandboxes/Logs.cs @@ -59,4 +59,10 @@ public static partial void SandboxActivityWorkerRegistered( Level = LogLevel.Debug, Message = "Ignoring on-demand sandbox heartbeat pump failure after registration session completion.")] public static partial void SandboxHeartbeatPumpFailureIgnored(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 709, + Level = LogLevel.Debug, + Message = "Ignoring on-demand sandbox worker session completion failure after heartbeat pump failure.")] + public static partial void SandboxWorkerSessionCompletionAfterHeartbeatFailureIgnored(ILogger logger, Exception exception); } diff --git a/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs b/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs index a8b11a33..74ff3187 100644 --- a/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs +++ b/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs @@ -187,6 +187,20 @@ or StackOverflowException or AccessViolationException or ThreadAbortException; + static async Task ObserveCompletionFailureAfterHeartbeatFailureAsync( + Task completionTask, + ILogger logger) + { + try + { + await completionTask.ConfigureAwait(false); + } + catch (Exception ex) when (!IsFatalException(ex)) + { + Logs.SandboxWorkerSessionCompletionAfterHeartbeatFailureIgnored(logger, ex); + } + } + async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancellationToken) { TimeSpan retryDelay = this.GetInitialRetryDelay(); @@ -309,7 +323,14 @@ async Task RunRegistrationSessionAsync( return; } - await heartbeatTask.ConfigureAwait(false); + try + { + await heartbeatTask.ConfigureAwait(false); + } + finally + { + _ = ObserveCompletionFailureAfterHeartbeatFailureAsync(completionTask, this.logger); + } } async Task PumpHeartbeatsAsync( diff --git a/src/Worker/Grpc/Internal/InternalOptionsExtensions.cs b/src/Worker/Grpc/Internal/InternalOptionsExtensions.cs index 764db9f7..a3ff7238 100644 --- a/src/Worker/Grpc/Internal/InternalOptionsExtensions.cs +++ b/src/Worker/Grpc/Internal/InternalOptionsExtensions.cs @@ -2,8 +2,6 @@ // Licensed under the MIT License. using System; -using System.Collections.Generic; -using System.Text; namespace Microsoft.DurableTask.Worker.Grpc.Internal; From b66381cf14f22027305972ee44db84fcc3edf922 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 12 Jun 2026 13:23:50 -0700 Subject: [PATCH 76/81] remove default worker profile --- samples/on-demand-sandbox/main-app/WorkerProfiles.cs | 4 ++-- .../SandboxWorkerProfileOptions.cs | 8 ++------ .../AzureManaged.Sandboxes/SandboxActivityMetadata.cs | 5 ----- .../DurableTaskSchedulerSandboxWorkerExtensions.cs | 6 +----- .../SandboxWorkerRuntimeOptions.cs | 4 +--- .../SandboxActivitiesClientTests.cs | 4 ++-- .../AzureManaged.Tests/SandboxActivitiesTests.cs | 11 +++++++++++ 7 files changed, 19 insertions(+), 23 deletions(-) diff --git a/samples/on-demand-sandbox/main-app/WorkerProfiles.cs b/samples/on-demand-sandbox/main-app/WorkerProfiles.cs index 8352c02f..f417a8d7 100644 --- a/samples/on-demand-sandbox/main-app/WorkerProfiles.cs +++ b/samples/on-demand-sandbox/main-app/WorkerProfiles.cs @@ -6,8 +6,8 @@ namespace Microsoft.DurableTask.Samples.OnDemandSandbox.MainApp; -[SandboxWorkerProfile("default")] -internal sealed class DefaultSandboxWorkerProfile : ISandboxWorkerProfile +[SandboxWorkerProfile("remote-hello-profile")] +internal sealed class RemoteHelloSandboxWorkerProfile : ISandboxWorkerProfile { public void Configure(SandboxWorkerProfileOptions options) { diff --git a/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileOptions.cs b/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileOptions.cs index 6bd00ba8..403f8690 100644 --- a/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileOptions.cs +++ b/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileOptions.cs @@ -12,11 +12,6 @@ namespace Microsoft.DurableTask.Client.AzureManaged; /// public sealed class SandboxWorkerProfileOptions { - /// - /// Default worker profile ID used when no profile is specified. - /// - internal const string DefaultWorkerProfileId = SandboxActivityMetadata.DefaultWorkerProfileId; - /// /// Gets or sets the task hub where the on-demand sandbox activity declaration is stored. /// @@ -24,8 +19,9 @@ public sealed class SandboxWorkerProfileOptions /// /// Gets or sets the worker profile ID used for the on-demand sandbox activity pool. + /// This value must be set explicitly. /// - public string WorkerProfileId { get; set; } = DefaultWorkerProfileId; + public string WorkerProfileId { get; set; } = string.Empty; /// /// Gets or sets the full OCI container image reference for on-demand sandbox workers. diff --git a/src/Shared/AzureManaged.Sandboxes/SandboxActivityMetadata.cs b/src/Shared/AzureManaged.Sandboxes/SandboxActivityMetadata.cs index 2dee0026..825aeda9 100644 --- a/src/Shared/AzureManaged.Sandboxes/SandboxActivityMetadata.cs +++ b/src/Shared/AzureManaged.Sandboxes/SandboxActivityMetadata.cs @@ -8,11 +8,6 @@ namespace Microsoft.DurableTask.AzureManaged.Internal; /// static class SandboxActivityMetadata { - /// - /// Default worker profile ID used when no profile is specified. - /// - public const string DefaultWorkerProfileId = "default"; - /// /// Resolves configured activity names for on-demand sandbox activity execution. /// diff --git a/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs index 2d1ec6bb..c1a57075 100644 --- a/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs +++ b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs @@ -182,11 +182,7 @@ static void ApplyWorkerEnvironmentOverrides(SandboxWorkerRuntimeOptions options) { ValidateSandboxWorkerSandboxProvider(GetRequiredEnvironmentVariable("DTS_SUBSTRATE")); - string? workerProfileId = Environment.GetEnvironmentVariable("DTS_WORKER_PROFILE_ID"); - if (!string.IsNullOrWhiteSpace(workerProfileId)) - { - options.WorkerProfileId = workerProfileId.Trim(); - } + options.WorkerProfileId = GetRequiredEnvironmentVariable("DTS_WORKER_PROFILE_ID"); if (int.TryParse(Environment.GetEnvironmentVariable("DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES"), out int maxActivities) && maxActivities > 0) { diff --git a/src/Worker/AzureManaged.Sandboxes/SandboxWorkerRuntimeOptions.cs b/src/Worker/AzureManaged.Sandboxes/SandboxWorkerRuntimeOptions.cs index 82cbd645..e3a82e46 100644 --- a/src/Worker/AzureManaged.Sandboxes/SandboxWorkerRuntimeOptions.cs +++ b/src/Worker/AzureManaged.Sandboxes/SandboxWorkerRuntimeOptions.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using Microsoft.DurableTask.AzureManaged.Internal; - namespace Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; /// @@ -18,7 +16,7 @@ internal sealed class SandboxWorkerRuntimeOptions /// /// Gets or sets the worker profile ID used by on-demand sandbox worker registration. /// - public string WorkerProfileId { get; set; } = SandboxActivityMetadata.DefaultWorkerProfileId; + public string WorkerProfileId { get; set; } = string.Empty; /// /// Gets or sets the maximum number of concurrent activities expected from this on-demand sandbox worker. diff --git a/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs index 2c9f8586..bbf43e17 100644 --- a/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs +++ b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs @@ -218,11 +218,11 @@ public async Task AddDurableTaskSchedulerSandboxActivitiesClient_UsesConfiguredD SandboxActivitiesClient client = provider.GetRequiredService(); // Act - await client.RemoveSandboxActivityDeclarationAsync("default"); + await client.RemoveSandboxActivityDeclarationAsync("profile-a"); // Assert callInvoker.RemoveRequest.Should().NotBeNull(); - callInvoker.RemoveRequest!.WorkerProfileId.Should().Be("default"); + callInvoker.RemoveRequest!.WorkerProfileId.Should().Be("profile-a"); callInvoker.RemoveHeaders.Should().Contain(header => header.Key == "taskhub" && header.Value == "client-test-taskhub"); } diff --git a/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs index 0324a17c..eb967faf 100644 --- a/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs +++ b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs @@ -506,6 +506,7 @@ public async Task UseSandboxWorker_ConfiguresRegisteredActivityWorkerFilter() // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "Sandbox"); @@ -540,6 +541,7 @@ public async Task UseSandboxWorker_WithNoRegisteredActivities_FailsWhenWorkerFil // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "Sandbox"); @@ -568,6 +570,7 @@ public async Task UseSandboxWorker_ConfiguresSchedulerWithManagedIdentityCredent // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); ServiceCollection services = new(); @@ -596,6 +599,7 @@ public void UseSandboxWorker_MissingAuthentication_Throws() // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", null); ServiceCollection services = new(); Mock mockBuilder = new(); @@ -616,6 +620,7 @@ public async Task UseSandboxWorker_InvalidAuthentication_ThrowsWhenSchedulerOpti // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentty"); ServiceCollection services = new(); Mock mockBuilder = new(); @@ -641,6 +646,7 @@ public async Task UseSandboxWorker_WithManagedIdentityAuth_ConfiguresSchedulerCr // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); ServiceCollection services = new(); @@ -669,6 +675,7 @@ public async Task UseSandboxWorker_WithManagedIdentityAuthAndMissingClientId_Thr // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", null); ServiceCollection services = new(); @@ -694,6 +701,7 @@ public void UseSandboxWorker_DoesNotRegisterWakeupServerHostedService() // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); ServiceCollection services = new(); Mock mockBuilder = new(); @@ -713,6 +721,7 @@ public void UseSandboxWorker_MissingInjectedEndpoint_Throws() // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", null); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); @@ -751,6 +760,7 @@ public async Task UseSandboxWorker_MissingInjectedSandboxProvider_ThrowsWhenWork // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", null); @@ -781,6 +791,7 @@ public async Task UseSandboxWorker_InvalidInjectedSandboxProvider_ThrowsWhenWork // Arrange using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "ContainerApp"); From 8a0e4f505e505aa94bf131dd5ff6e3dd30e41084 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 12 Jun 2026 13:56:23 -0700 Subject: [PATCH 77/81] Rename DTS_SUBSTRATE to DTS_SANDBOX_PROVIDER Replace legacy DTS_SUBSTRATE and DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES env vars with DTS_SANDBOX_PROVIDER and DTS_SANDBOX_MAX_ACTIVITIES. Updated worker code (environment parsing and validation), message builder, README, and tests to use the new names and updated validation/error messages. Keeps behavior the same while standardizing sandbox-related environment variable names. --- samples/on-demand-sandbox/README.md | 2 +- ...bleTaskSchedulerSandboxWorkerExtensions.cs | 12 +++++------ .../SandboxWorkerMessageBuilder.cs | 8 ++++---- .../SandboxActivitiesClientTests.cs | 2 +- .../SandboxActivitiesTests.cs | 20 +++++++++---------- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/samples/on-demand-sandbox/README.md b/samples/on-demand-sandbox/README.md index 040fa2fe..8a55f652 100644 --- a/samples/on-demand-sandbox/README.md +++ b/samples/on-demand-sandbox/README.md @@ -66,6 +66,6 @@ Output: "hello locally: on-demand-sandbox-sample; hello remotely from Use the Durable Task Scheduler dashboard's On-demand sandbox preview tab to inspect sandboxes and stream runtime logs. The remote worker image does not need customer-provided DTS runtime settings. -DTS injects the scheduler endpoint, task hub, worker profile, capacity, substrate, +DTS injects the scheduler endpoint, task hub, worker profile, capacity, sandbox provider, and sandbox identifier when it starts the sandbox. The worker reports the activities registered in the image when it connects. diff --git a/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs index c1a57075..ce797a18 100644 --- a/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs +++ b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs @@ -180,23 +180,23 @@ static string GetRequiredEnvironmentVariable(string name) static void ApplyWorkerEnvironmentOverrides(SandboxWorkerRuntimeOptions options) { - ValidateSandboxWorkerSandboxProvider(GetRequiredEnvironmentVariable("DTS_SUBSTRATE")); + ValidateSandboxWorkerSandboxProvider(GetRequiredEnvironmentVariable("DTS_SANDBOX_PROVIDER")); options.WorkerProfileId = GetRequiredEnvironmentVariable("DTS_WORKER_PROFILE_ID"); - if (int.TryParse(Environment.GetEnvironmentVariable("DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES"), out int maxActivities) && maxActivities > 0) + if (int.TryParse(Environment.GetEnvironmentVariable("DTS_SANDBOX_MAX_ACTIVITIES"), out int maxActivities) && maxActivities > 0) { options.MaxConcurrentActivities = maxActivities; } } - static void ValidateSandboxWorkerSandboxProvider(string substrate) + static void ValidateSandboxWorkerSandboxProvider(string sandboxProvider) { - if (!string.Equals(substrate, "Sandbox", StringComparison.OrdinalIgnoreCase) - && !string.Equals(substrate, "AcaSessionPool", StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(sandboxProvider, "Sandbox", StringComparison.OrdinalIgnoreCase) + && !string.Equals(sandboxProvider, "AcaSessionPool", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( - "DTS_SUBSTRATE must be 'Sandbox' or 'AcaSessionPool' for on-demand sandbox workers."); + "DTS_SANDBOX_PROVIDER must be 'Sandbox' or 'AcaSessionPool' for on-demand sandbox workers."); } } diff --git a/src/Worker/AzureManaged.Sandboxes/SandboxWorkerMessageBuilder.cs b/src/Worker/AzureManaged.Sandboxes/SandboxWorkerMessageBuilder.cs index 816016e1..c16a560f 100644 --- a/src/Worker/AzureManaged.Sandboxes/SandboxWorkerMessageBuilder.cs +++ b/src/Worker/AzureManaged.Sandboxes/SandboxWorkerMessageBuilder.cs @@ -81,18 +81,18 @@ public static Proto.SandboxActivityWorkerMessage BuildWorkerHeartbeat(int active static Proto.SandboxProviderKind GetSandboxProviderFromEnvironment() { - string? substrate = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); - if (substrate is null) + string? sandboxProvider = Environment.GetEnvironmentVariable("DTS_SANDBOX_PROVIDER"); + if (sandboxProvider is null) { return Proto.SandboxProviderKind.Unspecified; } - if (substrate.Equals("Sandbox", StringComparison.OrdinalIgnoreCase)) + if (sandboxProvider.Equals("Sandbox", StringComparison.OrdinalIgnoreCase)) { return Proto.SandboxProviderKind.Sandbox; } - if (substrate.Equals("AcaSessionPool", StringComparison.OrdinalIgnoreCase)) + if (sandboxProvider.Equals("AcaSessionPool", StringComparison.OrdinalIgnoreCase)) { return Proto.SandboxProviderKind.AcaSessionPool; } diff --git a/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs index bbf43e17..4f8de120 100644 --- a/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs +++ b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs @@ -146,7 +146,7 @@ public void SandboxActivityDeclarationProvider_ResolveDeclarations_UsesWorkerPro using EnvironmentVariableScope image = new("DTS_ON_DEMAND_SANDBOX_ACTIVITY_IMAGE", "example.com/not-used:latest"); using EnvironmentVariableScope cpu = new("DTS_ON_DEMAND_SANDBOX_CPU", "2000m"); using EnvironmentVariableScope memory = new("DTS_ON_DEMAND_SANDBOX_MEMORY", "4096Mi"); - using EnvironmentVariableScope maxActivities = new("DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES", "99"); + using EnvironmentVariableScope maxActivities = new("DTS_SANDBOX_MAX_ACTIVITIES", "99"); SandboxActivityDeclarationProvider provider = new(); diff --git a/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs index eb967faf..73d64aac 100644 --- a/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs +++ b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs @@ -93,9 +93,9 @@ public async Task SandboxActivitiesGrpcTransport_CanRelyOnChannelTaskHubMetadata public async Task SandboxActivityWorkerRegistrationHostedService_SendsLiveWorkerMetadataWithRegisteredActivities() { // Arrange - string? originalSandboxProvider = Environment.GetEnvironmentVariable("DTS_SUBSTRATE"); + string? originalSandboxProvider = Environment.GetEnvironmentVariable("DTS_SANDBOX_PROVIDER"); string? originalSandboxId = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID"); - Environment.SetEnvironmentVariable("DTS_SUBSTRATE", "Sandbox"); + Environment.SetEnvironmentVariable("DTS_SANDBOX_PROVIDER", "Sandbox"); Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", "sandbox-1"); try @@ -132,7 +132,7 @@ public async Task SandboxActivityWorkerRegistrationHostedService_SendsLiveWorker } finally { - Environment.SetEnvironmentVariable("DTS_SUBSTRATE", originalSandboxProvider); + Environment.SetEnvironmentVariable("DTS_SANDBOX_PROVIDER", originalSandboxProvider); Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", originalSandboxId); } } @@ -509,8 +509,8 @@ public async Task UseSandboxWorker_ConfiguresRegisteredActivityWorkerFilter() using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); - using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "Sandbox"); - using EnvironmentVariableScope maxActivities = new("DTS_ON_DEMAND_SANDBOX_MAX_ACTIVITIES", "3"); + using EnvironmentVariableScope sandboxProvider = new("DTS_SANDBOX_PROVIDER", "Sandbox"); + using EnvironmentVariableScope maxActivities = new("DTS_SANDBOX_MAX_ACTIVITIES", "3"); ServiceCollection services = new(); services.Configure( Options.DefaultName, @@ -544,7 +544,7 @@ public async Task UseSandboxWorker_WithNoRegisteredActivities_FailsWhenWorkerFil using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); - using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "Sandbox"); + using EnvironmentVariableScope sandboxProvider = new("DTS_SANDBOX_PROVIDER", "Sandbox"); ServiceCollection services = new(); Mock mockBuilder = new(); mockBuilder.Setup(builder => builder.Services).Returns(services); @@ -763,7 +763,7 @@ public async Task UseSandboxWorker_MissingInjectedSandboxProvider_ThrowsWhenWork using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); - using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", null); + using EnvironmentVariableScope sandboxProvider = new("DTS_SANDBOX_PROVIDER", null); ServiceCollection services = new(); services.Configure( Options.DefaultName, @@ -782,7 +782,7 @@ public async Task UseSandboxWorker_MissingInjectedSandboxProvider_ThrowsWhenWork // Assert action.Should().Throw() - .WithMessage("DTS_SUBSTRATE must be injected by DTS for on-demand sandbox workers."); + .WithMessage("DTS_SANDBOX_PROVIDER must be injected by DTS for on-demand sandbox workers."); } [Fact] @@ -794,7 +794,7 @@ public async Task UseSandboxWorker_InvalidInjectedSandboxProvider_ThrowsWhenWork using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); - using EnvironmentVariableScope substrate = new("DTS_SUBSTRATE", "ContainerApp"); + using EnvironmentVariableScope sandboxProvider = new("DTS_SANDBOX_PROVIDER", "ContainerApp"); ServiceCollection services = new(); services.Configure( Options.DefaultName, @@ -813,7 +813,7 @@ public async Task UseSandboxWorker_InvalidInjectedSandboxProvider_ThrowsWhenWork // Assert action.Should().Throw() - .WithMessage("DTS_SUBSTRATE must be 'Sandbox' or 'AcaSessionPool' for on-demand sandbox workers."); + .WithMessage("DTS_SANDBOX_PROVIDER must be 'Sandbox' or 'AcaSessionPool' for on-demand sandbox workers."); } sealed class FakeSandboxActivitiesTransport : ISandboxActivitiesTransport From 6dda811c8b2910f18e1b3ae6e20504919cdbba62 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 12 Jun 2026 17:03:21 -0700 Subject: [PATCH 78/81] Harden sandbox worker runtime config --- samples/on-demand-sandbox/README.md | 15 ++++---- .../main-app/WorkerProfiles.cs | 6 ++-- ...bleTaskSchedulerSandboxWorkerExtensions.cs | 13 +++++-- .../SandboxActivitiesTests.cs | 36 +++++++++++++++++++ 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/samples/on-demand-sandbox/README.md b/samples/on-demand-sandbox/README.md index 8a55f652..83c7dbeb 100644 --- a/samples/on-demand-sandbox/README.md +++ b/samples/on-demand-sandbox/README.md @@ -30,12 +30,15 @@ docker push $image ## Run a hello orchestration The main app uses `DefaultAzureCredential`; sign in with Azure CLI or configure another supported Azure identity before running it. -After pushing the remote worker image, set `ContainerImage` in -`main-app/WorkerProfiles.cs` to the pushed image reference. The worker profile -class declares the image, CPU, memory, max concurrency, and on-demand sandbox activity -names with `options.AddActivity(...)`. The main app and remote worker both use -the `shared/ActivityNames.cs` constants so the declaration and worker registration -stay in sync. +After pushing the remote worker image, set these environment variables: + +```powershell +$env:DTS_SANDBOX_CONTAINER_IMAGE = "" +$env:DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID = "" +$env:DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID = "" +``` + +The worker profile class declares the image, CPU, memory, max concurrency, and on-demand sandbox activity names with `options.AddActivity(...)`. The main app and remote worker both use the `shared/ActivityNames.cs` constants so the declaration and worker registration stay in sync. Update `main-app/appsettings.json` with your scheduler endpoint and task hub: diff --git a/samples/on-demand-sandbox/main-app/WorkerProfiles.cs b/samples/on-demand-sandbox/main-app/WorkerProfiles.cs index f417a8d7..e7c899ec 100644 --- a/samples/on-demand-sandbox/main-app/WorkerProfiles.cs +++ b/samples/on-demand-sandbox/main-app/WorkerProfiles.cs @@ -11,9 +11,9 @@ internal sealed class RemoteHelloSandboxWorkerProfile : ISandboxWorkerProfile { public void Configure(SandboxWorkerProfileOptions options) { - options.ContainerImage = Environment.GetEnvironmentVariable("DTS_ON_DEMAND_SANDBOX_CONTAINER_IMAGE") ?? "on-demand-sandbox-remote-worker:local"; - options.ImagePullManagedIdentityClientId = GetRequiredEnvironmentVariable("DTS_ON_DEMAND_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID"); - options.SchedulerManagedIdentityClientId = GetRequiredEnvironmentVariable("DTS_ON_DEMAND_SANDBOX_SCHEDULER_UMI_CLIENT_ID"); + options.ContainerImage = Environment.GetEnvironmentVariable("DTS_SANDBOX_CONTAINER_IMAGE") ?? "on-demand-sandbox-remote-worker:local"; + options.ImagePullManagedIdentityClientId = GetRequiredEnvironmentVariable("DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID"); + options.SchedulerManagedIdentityClientId = GetRequiredEnvironmentVariable("DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID"); options.Cpu = "1000m"; options.Memory = "2048Mi"; options.MaxConcurrentActivities = 1; diff --git a/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs index ce797a18..890c6903 100644 --- a/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs +++ b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs @@ -184,10 +184,19 @@ static void ApplyWorkerEnvironmentOverrides(SandboxWorkerRuntimeOptions options) options.WorkerProfileId = GetRequiredEnvironmentVariable("DTS_WORKER_PROFILE_ID"); - if (int.TryParse(Environment.GetEnvironmentVariable("DTS_SANDBOX_MAX_ACTIVITIES"), out int maxActivities) && maxActivities > 0) + string? maxActivitiesValue = Environment.GetEnvironmentVariable("DTS_SANDBOX_MAX_ACTIVITIES"); + if (maxActivitiesValue is null) { - options.MaxConcurrentActivities = maxActivities; + return; } + + if (!int.TryParse(maxActivitiesValue.Trim(), out int maxActivities) || maxActivities <= 0) + { + throw new InvalidOperationException( + "DTS_SANDBOX_MAX_ACTIVITIES must be a positive integer when injected by DTS for on-demand sandbox workers."); + } + + options.MaxConcurrentActivities = maxActivities; } static void ValidateSandboxWorkerSandboxProvider(string sandboxProvider) diff --git a/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs index 73d64aac..c53fe53b 100644 --- a/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs +++ b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs @@ -535,6 +535,42 @@ public async Task UseSandboxWorker_ConfiguresRegisteredActivityWorkerFilter() workerOptions.Concurrency.MaximumConcurrentEntityWorkItems.Should().Be(0); } + [Theory] + [InlineData("")] + [InlineData("0")] + [InlineData("-1")] + [InlineData("many")] + public async Task UseSandboxWorker_InvalidMaxActivities_ThrowsWhenWorkerOptionsAreResolved(string maxActivitiesValue) + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); + using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); + using EnvironmentVariableScope sandboxProvider = new("DTS_SANDBOX_PROVIDER", "Sandbox"); + using EnvironmentVariableScope maxActivities = new("DTS_SANDBOX_MAX_ACTIVITIES", maxActivitiesValue); + ServiceCollection services = new(); + services.Configure( + Options.DefaultName, + registry => registry.AddActivityFunc(new TaskName("RemoteHello"), (_, input) => input)); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + mockBuilder.Object.UseSandboxWorker(); + await using ServiceProvider provider = services.BuildServiceProvider(); + + // Act + Action action = () => provider + .GetRequiredService>() + .Get(Options.DefaultName); + + // Assert + action.Should().Throw() + .WithMessage("DTS_SANDBOX_MAX_ACTIVITIES must be a positive integer when injected by DTS for on-demand sandbox workers."); + } + [Fact] public async Task UseSandboxWorker_WithNoRegisteredActivities_FailsWhenWorkerFiltersAreResolved() { From 61dd9517b20144b3285185a31dbbbfe32f601ae3 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 12 Jun 2026 17:08:51 -0700 Subject: [PATCH 79/81] Update sandbox proto sync path --- src/Grpc/refresh-protos.ps1 | 25 +++++++++++++++++-------- src/Grpc/versions.txt | 6 +++--- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/Grpc/refresh-protos.ps1 b/src/Grpc/refresh-protos.ps1 index a1510563..8458d10c 100644 --- a/src/Grpc/refresh-protos.ps1 +++ b/src/Grpc/refresh-protos.ps1 @@ -17,15 +17,23 @@ $commitDetails = Invoke-RestMethod -Uri "https://api.github.com/repos/microsoft/ $commitId = $commitDetails.sha # These are the proto files we need to download from the durabletask-protobuf repository. -$protoFileNames = @( - "orchestrator_service.proto", - "sandbox_service.proto" +$protoFiles = @( + @{ + SourcePath = "orchestrator_service.proto" + OutputFileName = "orchestrator_service.proto" + }, + @{ + SourcePath = "durable-task-scheduler/sandbox_service.proto" + OutputFileName = "sandbox_service.proto" + } ) # Download each proto file to the local directory using the above commit ID -foreach ($protoFileName in $protoFileNames) { - $url = "https://raw.githubusercontent.com/microsoft/durabletask-protobuf/$commitId/protos/$protoFileName" - $outputFile = "$PSScriptRoot\$protoFileName" +foreach ($protoFile in $protoFiles) { + $sourcePath = $protoFile.SourcePath + $outputFileName = $protoFile.OutputFileName + $url = "https://raw.githubusercontent.com/microsoft/durabletask-protobuf/$commitId/protos/$sourcePath" + $outputFile = "$PSScriptRoot\$outputFileName" try { Invoke-WebRequest -Uri $url -OutFile $outputFile @@ -47,10 +55,11 @@ Add-Content ` -Path $versionsFile ` -Value "# The following files were downloaded from branch $branch at $(Get-Date -Format "yyyy-MM-dd HH:mm:ss" -AsUTC) UTC" -foreach ($protoFileName in $protoFileNames) { +foreach ($protoFile in $protoFiles) { + $sourcePath = $protoFile.SourcePath Add-Content ` -Path $versionsFile ` - -Value "https://raw.githubusercontent.com/microsoft/durabletask-protobuf/$commitId/protos/$protoFileName" + -Value "https://raw.githubusercontent.com/microsoft/durabletask-protobuf/$commitId/protos/$sourcePath" } Write-Host "Wrote commit ID $commitId to $versionsFile" -ForegroundColor Green diff --git a/src/Grpc/versions.txt b/src/Grpc/versions.txt index 2c8f46cb..7a66a700 100644 --- a/src/Grpc/versions.txt +++ b/src/Grpc/versions.txt @@ -1,3 +1,3 @@ -# The following files were downloaded from branch wangbill/on-demand-sandbox-protobuf at 2026-06-12 00:07:59 UTC -https://raw.githubusercontent.com/microsoft/durabletask-protobuf/67da3dbdb4a567c8892c7133246706358a53e1e8/protos/orchestrator_service.proto -https://raw.githubusercontent.com/microsoft/durabletask-protobuf/67da3dbdb4a567c8892c7133246706358a53e1e8/protos/sandbox_service.proto +# The following files were downloaded from branch wangbill/on-demand-sandbox-protobuf at 2026-06-13 00:07:36 UTC +https://raw.githubusercontent.com/microsoft/durabletask-protobuf/cd99bc4f734eb9abc5fad49b76eea7a1396f3216/protos/orchestrator_service.proto +https://raw.githubusercontent.com/microsoft/durabletask-protobuf/cd99bc4f734eb9abc5fad49b76eea7a1396f3216/protos/durable-task-scheduler/sandbox_service.proto From 8ce3888fd1d8b28b4efb801b43a23a9b796f8d54 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 12 Jun 2026 17:16:59 -0700 Subject: [PATCH 80/81] Tighten sandbox activity metadata validation --- .../SandboxActivityDeclarationBuilder.cs | 8 ++++---- .../SandboxActivityMetadata.cs | 2 +- .../SandboxActivitiesClientTests.cs | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationBuilder.cs b/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationBuilder.cs index bc6c0759..5932f0d3 100644 --- a/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationBuilder.cs +++ b/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationBuilder.cs @@ -150,12 +150,12 @@ static string NormalizeMemory(string value) { if (value.EndsWith('m') || value.EndsWith('M')) { - return decimal.TryParse( + return long.TryParse( value[..^1], - NumberStyles.Number, + NumberStyles.Integer, CultureInfo.InvariantCulture, - out decimal milliCpu) - ? (long)milliCpu + out long milliCpu) + ? milliCpu : null; } diff --git a/src/Shared/AzureManaged.Sandboxes/SandboxActivityMetadata.cs b/src/Shared/AzureManaged.Sandboxes/SandboxActivityMetadata.cs index 825aeda9..469dfba9 100644 --- a/src/Shared/AzureManaged.Sandboxes/SandboxActivityMetadata.cs +++ b/src/Shared/AzureManaged.Sandboxes/SandboxActivityMetadata.cs @@ -18,7 +18,7 @@ public static string[] ResolveActivityNames(IEnumerable configuredNames) return configuredNames .Where(static name => !string.IsNullOrWhiteSpace(name)) .Select(static name => name.Trim()) - .Distinct(StringComparer.Ordinal) + .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); } diff --git a/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs index 4f8de120..56baf84a 100644 --- a/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs +++ b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs @@ -81,6 +81,7 @@ public void SandboxActivityDeclarationBuilder_BuildDeclaration_AcceptsAdcResourc [Theory] [InlineData("0", "1024Mi", "CPU")] [InlineData("0m", "1024Mi", "CPU")] + [InlineData("500.5m", "1024Mi", "CPU")] [InlineData("500Mi", "1024Mi", "CPU")] [InlineData("500m", "0", "memory")] [InlineData("500m", "0Mi", "memory")] @@ -105,6 +106,22 @@ public void SandboxActivityDeclarationBuilder_BuildDeclaration_RejectsInvalidAdc .WithMessage($"*{expectedMessage}*"); } + [Fact] + public void SandboxActivityDeclarationBuilder_ResolveActivityNames_DeduplicatesCaseInsensitively() + { + // Arrange + SandboxWorkerProfileOptions options = CreateDeclarationOptions(); + options.AddActivity(" RemoteHello "); + options.AddActivity("remotehello"); + options.AddActivity("Other"); + + // Act + string[] activityNames = SandboxActivityDeclarationBuilder.ResolveActivityNames(options.ActivityNames); + + // Assert + activityNames.Should().Equal("RemoteHello", "Other"); + } + [Fact] public void SandboxActivityDeclarationBuilder_BuildDeclaration_RequiresSchedulerManagedIdentityClientId() { From 74df39ede05d4bd364a9f64b95ec189bb07c3558 Mon Sep 17 00:00:00 2001 From: wangbill Date: Fri, 12 Jun 2026 17:27:37 -0700 Subject: [PATCH 81/81] Apply sandbox image proto updates --- .../SandboxActivityDeclarationBuilder.cs | 4 +-- src/Grpc/sandbox_service.proto | 36 +++++++++++-------- src/Grpc/versions.txt | 6 ++-- .../SandboxActivitiesClientTests.cs | 4 +-- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationBuilder.cs b/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationBuilder.cs index 5932f0d3..9229f913 100644 --- a/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationBuilder.cs +++ b/src/Client/AzureManaged.Sandboxes/SandboxActivityDeclarationBuilder.cs @@ -64,8 +64,8 @@ public static Proto.SandboxActivityDeclaration BuildDeclaration( declaration.ActivityNames.AddRange(activityNames); declaration.EnvironmentVariables.Add(options.EnvironmentVariables); - declaration.Entrypoint.AddRange(NormalizeOptionalStrings(options.Entrypoint)); - declaration.Cmd.AddRange(NormalizeOptionalStrings(options.Cmd)); + declaration.Image.Entrypoint.AddRange(NormalizeOptionalStrings(options.Entrypoint)); + declaration.Image.Cmd.AddRange(NormalizeOptionalStrings(options.Cmd)); return declaration; } diff --git a/src/Grpc/sandbox_service.proto b/src/Grpc/sandbox_service.proto index 2fd9d513..46e548ee 100644 --- a/src/Grpc/sandbox_service.proto +++ b/src/Grpc/sandbox_service.proto @@ -14,6 +14,8 @@ service SandboxActivities { rpc ConnectSandboxActivityWorker(stream SandboxActivityWorkerMessage) returns (SandboxActivityWorkerSessionResult); // Declares sandbox activities before any live worker stream exists. + // This private preview supports activity execution only as a business + // decision; orchestrations and entities are not part of this contract. // This is a configuration contract and does not advertise active worker // capacity. rpc DeclareSandboxActivities(SandboxActivityDeclaration) returns (SandboxActivityDeclarationResult); @@ -33,17 +35,19 @@ message SandboxActivityWorkerMessage { message SandboxActivityWorkerStart { string task_hub = 1; - int32 max_activities_count = 3; + int32 max_activities_count = 2; // Sandbox provider the worker is running in. UNSPECIFIED = legacy // (pre-provider-aware) workers. - SandboxProviderKind sandbox_provider = 4; + SandboxProviderKind sandbox_provider = 3; // DTS-generated sandbox identifier injected as DTS_SANDBOX_ID. This is not // the ADC provider sandbox resource id. - string dts_sandbox_identifier = 5; - string worker_profile_id = 6; + string dts_sandbox_identifier = 4; + // Caller-defined profile/catalog ID shared by declarations and live worker + // registration. This is not a live worker process/session ID. + string worker_profile_id = 5; // Activity handlers registered by the worker process. DTS validates this // matches the declaration before advertising worker capacity. - repeated string activity_names = 7; + repeated string activity_names = 6; } message SandboxActivityWorkerHeartbeat { @@ -51,24 +55,26 @@ message SandboxActivityWorkerHeartbeat { } message SandboxActivityWorkerSessionResult { - string message = 2; + string message = 1; } message SandboxActivityDeclaration { - string worker_profile_id = 2; - repeated string activity_names = 3; - SandboxActivityImage image = 4; - map environment_variables = 5; - int32 max_concurrent_activities = 6; - SandboxActivityResources resources = 7; - repeated string entrypoint = 8; - repeated string cmd = 9; - string scheduler_managed_identity_client_id = 10; + // Caller-defined profile/catalog ID shared by declarations and live worker + // registration. This is not a live worker process/session ID. + string worker_profile_id = 1; + repeated string activity_names = 2; + SandboxActivityImage image = 3; + map environment_variables = 4; + int32 max_concurrent_activities = 5; + SandboxActivityResources resources = 6; + string scheduler_managed_identity_client_id = 7; } message SandboxActivityImage { string image_ref = 1; string managed_identity_client_id = 2; + repeated string entrypoint = 3; + repeated string cmd = 4; } message SandboxActivityResources { diff --git a/src/Grpc/versions.txt b/src/Grpc/versions.txt index 7a66a700..5b033880 100644 --- a/src/Grpc/versions.txt +++ b/src/Grpc/versions.txt @@ -1,3 +1,3 @@ -# The following files were downloaded from branch wangbill/on-demand-sandbox-protobuf at 2026-06-13 00:07:36 UTC -https://raw.githubusercontent.com/microsoft/durabletask-protobuf/cd99bc4f734eb9abc5fad49b76eea7a1396f3216/protos/orchestrator_service.proto -https://raw.githubusercontent.com/microsoft/durabletask-protobuf/cd99bc4f734eb9abc5fad49b76eea7a1396f3216/protos/durable-task-scheduler/sandbox_service.proto +# The following files were downloaded from branch wangbill/on-demand-sandbox-protobuf at 2026-06-13 00:25:34 UTC +https://raw.githubusercontent.com/microsoft/durabletask-protobuf/49a4dea8f9d8f62f026645135ec9bad71b73d7b4/protos/orchestrator_service.proto +https://raw.githubusercontent.com/microsoft/durabletask-protobuf/49a4dea8f9d8f62f026645135ec9bad71b73d7b4/protos/durable-task-scheduler/sandbox_service.proto diff --git a/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs index 56baf84a..df7ec42c 100644 --- a/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs +++ b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs @@ -184,8 +184,8 @@ public void SandboxActivityDeclarationProvider_ResolveDeclarations_UsesWorkerPro declaration.Resources.Memory.Should().Be("1024Mi"); declaration.MaxConcurrentActivities.Should().Be(4); declaration.EnvironmentVariables.Should().ContainKey("CUSTOM_ENV").WhoseValue.Should().Be("configured-value"); - declaration.Entrypoint.Should().BeEmpty(); - declaration.Cmd.Should().BeEmpty(); + declaration.Image.Entrypoint.Should().BeEmpty(); + declaration.Image.Cmd.Should().BeEmpty(); } [Fact]