From 6723ddefc99a3115fc4a4faf7989a9516a6e30ca Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 13 May 2026 19:29:22 -0700 Subject: [PATCH 01/30] 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 3d7c8eb49..f782a5fe8 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 000000000..ee3bbb79d --- /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 3b9d4e55c..c25bbf103 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 000000000..528bbd1fc --- /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 000000000..702d8993e --- /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 000000000..4825c44ba --- /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 000000000..35e6e0c4c --- /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 000000000..47ccfafcf --- /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 000000000..27ed6bb70 --- /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 a92740788..50e967aeb 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 c8eefb12c..bcb452123 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 8a5df2f1d..ec24fd2eb 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 176d376c1..63c2b052d 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 000000000..1c749a94f --- /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/30] 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 ee3bbb79d..a590427ff 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 c25bbf103..55a427ea5 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 528bbd1fc..3c18bf3f6 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 4825c44ba..814d66214 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 35e6e0c4c..ee8bf78d5 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 1c749a94f..4af28d366 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/30] 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 0b8ef9359..467ee7625 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 000000000..a8e8fed65 --- /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 000000000..969407408 --- /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 000000000..06389a45b --- /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 000000000..b3050fbcb --- /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 27ed6bb70..4ac4735e8 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 3c18bf3f6..662394285 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 702d8993e..eecf5fda8 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 47ccfafcf..eb9b8a952 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 000000000..4551797a2 --- /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 a590427ff..fc27c0e71 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 55a427ea5..2b832a54b 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 814d66214..000000000 --- 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 ee8bf78d5..000000000 --- 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 000000000..a03e480ee --- /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 000000000..104cb30ab --- /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 4af28d366..b7b809641 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/30] 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 b3050fbcb..135253404 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 662394285..e278fcf56 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 4551797a2..512547ea6 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 fc27c0e71..dba515597 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 b7b809641..3f022fa4d 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/30] 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 135253404..4b5623110 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 512547ea6..2b6cdef17 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/30] 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 000000000..532247e38 --- /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 eecf5fda8..05ce1e588 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 eb9b8a952..914b30974 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/30] 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 532247e38..dd6729d73 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 05ce1e588..8c7933885 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 914b30974..60cd08b96 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 2b6cdef17..161413bb3 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 dba515597..f77c27bed 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 3f022fa4d..9d2d15b48 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/30] 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 a8e8fed65..578fe8883 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 4b5623110..3cd3f0984 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 000000000..36237ce2a --- /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 60cd08b96..77df6a59a 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 161413bb3..306bd3f62 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 000000000..2250f344b --- /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 4c5a18b2b..40a2240b9 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 49bd63507..59c21a008 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 b26b36cc6..764db9f76 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 9d2d15b48..d3de7b542 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 db6c98da5..0fe26c553 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/30] 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 77df6a59a..2152a8ee0 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 306bd3f62..27a62a762 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 d3de7b542..dffc62c66 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/30] 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 4ac4735e8..dffa128e7 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 2152a8ee0..13c4cc499 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 dffc62c66..2b61f4733 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/30] 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 13c4cc499..f7226bea4 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 2b61f4733..917af64ca 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/30] 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 dffa128e7..bda90dbc5 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/30] 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 3cd3f0984..c98bd2037 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 bda90dbc5..2ad14739c 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 917af64ca..53f1c71ef 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/30] 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 969407408..4a7f3a8c2 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 000000000..b9aa6cde2 --- /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 06389a45b..f7dbd7cbd 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 dd6729d73..3f1bbfa0f 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 e278fcf56..85fc06456 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 f7226bea4..a3b858410 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 27a62a762..4619350fb 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 f77c27bed..a99e1d5e6 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 104cb30ab..067bd496e 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 53f1c71ef..e0da0b07a 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/30] 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 4a7f3a8c2..892b19853 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 067bd496e..e0e8420d8 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/30] 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 467ee7625..e91474aa9 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 7226f2011..2094e890d 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 000000000..0a21e54df --- /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 000000000..b8eedf5a0 --- /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 000000000..9aa544b8c --- /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 000000000..584b86a8d --- /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 000000000..c03b9c54a --- /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 000000000..a4f1cba17 --- /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 000000000..1b3279671 --- /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 000000000..04de4f425 --- /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 000000000..a195af8d6 --- /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 000000000..84e5977c7 --- /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 000000000..18f24b23d --- /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 000000000..9b8836cf5 --- /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 000000000..fc89b9a99 --- /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 000000000..483ab823b --- /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 000000000..41050b33b --- /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/30] 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 0a21e54df..b9c5d64f4 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 9aa544b8c..000000000 --- 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 c03b9c54a..61a616978 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 a4f1cba17..65fbc0b19 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 fc89b9a99..000000000 --- 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 483ab823b..1107f7604 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/30] 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 b9c5d64f4..9230978d3 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 61a616978..c8818774a 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 65fbc0b19..ce41adf1a 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 1b3279671..a74309efd 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 04de4f425..c14cfc4eb 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 a195af8d6..65647d919 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 1107f7604..e1de49722 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 41050b33b..c358c3cb5 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 000000000..8801d01b0 --- /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 000000000..ff0ed57a5 --- /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 e0e8420d8..f742c15d4 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/30] 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 c8818774a..8a23811d2 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 ce41adf1a..d4bb92e73 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 e1de49722..d29fbc831 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/30] 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 e91474aa9..051dd2c1b 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 9230978d3..a47ef90c1 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 b8eedf5a0..000000000 --- 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 584b86a8d..000000000 --- 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 8a23811d2..000000000 --- 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 000000000..73c5caee4 --- /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 000000000..b5fedd880 --- /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 000000000..e1704e588 --- /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 d4bb92e73..8173b2f1f 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 a74309efd..1787c2d59 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 c14cfc4eb..692756dca 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 65647d919..d9025d966 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 84e5977c7..51a576c1a 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 d29fbc831..5389a5b1d 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 a3b858410..ca51abc77 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/30] 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 a47ef90c1..8faff0e26 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 e1704e588..84da2345c 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 8173b2f1f..000000000 --- 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 1787c2d59..000000000 --- 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 692756dca..000000000 --- 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 d9025d966..d73515dca 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/30] 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 8801d01b0..01f32ae75 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 892b19853..05de35307 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 b9aa6cde2..000000000 --- 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 f7dbd7cbd..000000000 --- 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 a99e1d5e6..4aa520d61 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 f742c15d4..9db1c9ad2 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/30] 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 4aa520d61..184006d25 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/30] 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 5389a5b1d..ac9a97432 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 c98bd2037..7f5b04147 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 85fc06456..90361c238 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 4619350fb..7b63255b1 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 e0da0b07a..61a531543 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/30] 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 ac9a97432..5389a5b1d 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 7f5b04147..c98bd2037 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 90361c238..85fc06456 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 7b63255b1..4619350fb 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 61a531543..e0da0b07a 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/30] 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 8faff0e26..d6ce29b17 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 84da2345c..f5ca7c25c 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 5389a5b1d..b705fba5a 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 578fe8883..bc2126839 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 c98bd2037..5179a6963 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 85fc06456..c9a3f5e7d 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 ca51abc77..ce506c101 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 184006d25..f37cd62a8 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 e0da0b07a..18f99a4a9 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/30] 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 bc2126839..413b65ea7 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 5179a6963..c6889ad75 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 4619350fb..d30d88bc0 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 2250f344b..000000000 --- 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 18f99a4a9..976802230 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/30] 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 c6889ad75..b99d41036 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 c9a3f5e7d..89b40ef62 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 8c7933885..4c3d85900 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 ce506c101..fa00e396d 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 d30d88bc0..6057a9673 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 000000000..168527463 --- /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 976802230..c8c315f76 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/30] 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 f5ca7c25c..b589e6cbb 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 d73515dca..f3987d2ad 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 b99d41036..695408f14 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 6057a9673..6f2660a3c 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 c8c315f76..f7d04b719 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/30] 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 89b40ef62..9e8bc2f60 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 6f2660a3c..e3d07e113 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 f37cd62a8..153d62db0 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 f7d04b719..f2d8b4625 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() {