diff --git a/CHANGELOG.md b/CHANGELOG.md index badfc014..dfa6e989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,465 +1,468 @@ -# Changelog - -## Unreleased - - - -## v1.25.0-preview.1 -- Implement per-orchestration and per-activity versioning ([#695](https://github.com/microsoft/durabletask-dotnet/pull/695)) -- Add es-metadata.yml to schema 1.0.0 ([#722](https://github.com/microsoft/durabletask-dotnet/pull/722)) -- Release v1.24.2 ([#724](https://github.com/microsoft/durabletask-dotnet/pull/724)) -- Add per-task versioning via `[DurableTask(Version = "...")]`, `TaskOptions.Version`, and `StartOrchestrationOptions.Version` ([#695](https://github.com/microsoft/durabletask-dotnet/pull/695)) - - -## v1.24.2 -- Bump DI.Abstractions and Bcl.AsyncInterfaces to 9.0.1 ([#3433](https://github.com/microsoft/durabletask-dotnet/pull/3433)) (#723) -- Validate UseWorkItemFilters names against registered tasks at worker build time ([#719](https://github.com/microsoft/durabletask-dotnet/pull/719)) -- Bump `Microsoft.Extensions.DependencyInjection.Abstractions` from 8.0.2 to 9.0.1 (and `Microsoft.Bcl.AsyncInterfaces` from 8.0.0 to 9.0.1, which the former transitively floors at 9.0.1) to align with the floor declared by `Microsoft.Azure.WebJobs 3.0.45 -> Microsoft.Extensions.Logging.Abstractions 9.0.1`. Fixes NU1605 in downstream Azure Functions Worker isolated apps consuming `Microsoft.DurableTask.Extensions.AzureBlobPayloads` ([Azure/azure-functions-durable-extension#3433](https://github.com/Azure/azure-functions-durable-extension/issues/3433)). -- Validate explicit `UseWorkItemFilters(filters)` filter names against the worker's `DurableTaskRegistry`. Filters that reference an orchestration, activity, or entity name not registered with the worker now throw `OptionsValidationException` at worker startup instead of silently waiting for work items that will never arrive. No customer-side validation call is required. ([#719](https://github.com/microsoft/durabletask-dotnet/pull/719)) - -## 1.24.1 -- Add retry to grpc calls that failed due to transient errors by @sophiatev ([#714](https://github.com/microsoft/durabletask-dotnet/pull/714)) - -## v1.24.0 -- Harden grpc worker and client against silent disconnects by @berndverst ([#708](https://github.com/microsoft/durabletask-dotnet/pull/708)) -- Preserve late events after continue-as-new by @berndverst ([#711](https://github.com/microsoft/durabletask-dotnet/pull/711)) -- Fix inprocesstesthost continueasnew stuck-instance race condition by @bachuv ([#707](https://github.com/microsoft/durabletask-dotnet/pull/707)) -- Fix continue-as-new race condition at inprocesstesthost by @nytian ([#703](https://github.com/microsoft/durabletask-dotnet/pull/703)) -- Add opt-in timeout to purgeinstancesfilter for partial purge by @yunchuwang ([#680](https://github.com/microsoft/durabletask-dotnet/pull/680)) - -## v1.23.3 -- fix: revert shared framework packages to 8.x for net8 Functions host compatibility ([#698](https://github.com/microsoft/durabletask-dotnet/pull/698)) -- Release v1.23.2 ([#693](https://github.com/microsoft/durabletask-dotnet/pull/693)) - -## v1.23.2 -- fix: improve large payload error handling — better error message and prevent infinite retry and fix conflict with auto chunking ([#691](https://github.com/microsoft/durabletask-dotnet/pull/691)) -- Bump dotnet-sdk from 10.0.103 to 10.0.201 ([#673](https://github.com/microsoft/durabletask-dotnet/pull/673)) -- Bump Microsoft.Azure.DurableTask.Core from 3.7.0 to 3.7.1 ([#685](https://github.com/microsoft/durabletask-dotnet/pull/685)) -- feat(copilot): add evidence-based Copilot customizations ([#690](https://github.com/microsoft/durabletask-dotnet/pull/690)) - -## v1.23.1 -- Fix CHANGELOG line ending preservation in Prepare Release workflow ([#687](https://github.com/microsoft/durabletask-dotnet/pull/687)) -- Add Prepare Release GitHub Action for automated release kickoff ([#686](https://github.com/microsoft/durabletask-dotnet/pull/686)) -- Add ContinueAsNewOptions with NewVersion support ([#682](https://github.com/microsoft/durabletask-dotnet/pull/682)) -- Fix concurrent timer race condition in InMemoryOrchestrationService ([#678](https://github.com/microsoft/durabletask-dotnet/pull/678)) - -## v1.23.0 -- Generate extension methods in task namespace instead of Microsoft.DurableTask ([#538](https://github.com/microsoft/durabletask-dotnet/pull/538)) -- Fix #668: Change work item filters from auto opt-in to explicit opt-in ([#669](https://github.com/microsoft/durabletask-dotnet/pull/669)) -- Add `ReplaySafeLoggerFactory` for context wrappers ([#670](https://github.com/microsoft/durabletask-dotnet/pull/670)) -- Add NuGet publish job for Microsoft.DurableTask.Analyzers ([#662](https://github.com/microsoft/durabletask-dotnet/pull/662)) -- Bump Azure.Identity from 1.17.1 to 1.18.0 ([#656](https://github.com/microsoft/durabletask-dotnet/pull/656)) -- Bump Microsoft.Azure.Functions.Worker.Extensions.DurableTask from 1.12.1 to 1.15.0 ([#658](https://github.com/microsoft/durabletask-dotnet/pull/658)) -- Add missing input validation to SuspendInstanceAsync and ResumeInstanceAsync ([#652](https://github.com/microsoft/durabletask-dotnet/pull/652)) -- Add ExportHistory package to NuGet publish pipeline ([#651](https://github.com/microsoft/durabletask-dotnet/pull/651)) -- Add OpenTelemetry sample and update deps ([#637](https://github.com/microsoft/durabletask-dotnet/pull/637)) -- Fix build warnings and clean up exception message ([#647](https://github.com/microsoft/durabletask-dotnet/pull/647)) - -## v1.22.0 -- Changing the default dedupe statuses behavior by sophiatev ([#622](https://github.com/microsoft/durabletask-dotnet/pull/622)) -- Bump Analyzers package version to 1.22.0 stable release (from 0.3.0) -- Add DURABLE0011: ContinueAsNew warning for unbounded orchestration loops ([#660](https://github.com/microsoft/durabletask-dotnet/pull/660)) - -## 1.21.0 -- Introduce WorkItemFilters into worker flow by halspang ([#616](https://github.com/microsoft/durabletask-dotnet/pull/616)) -- Fix Analyzers treating passed in variable argument name as null by wangbill ([#640](https://github.com/microsoft/durabletask-dotnet/pull/640)) -- Move DURABLE0009/0010 from Unshipped to Shipped for v0.3.0 by cgillum ([#641](https://github.com/microsoft/durabletask-dotnet/pull/641)) - -## 1.20.1 -- Fix GrpcChannel handle leak in AzureManaged backendby nytian ([#629](https://github.com/microsoft/durabletask-dotnet/pull/629)) - -## 1.20.0 -- Partial orchestration workitem completion support (merge after next dts dp release) by wangbill ([#514](https://github.com/microsoft/durabletask-dotnet/pull/514)) -- Export history job by wangbill ([#494](https://github.com/microsoft/durabletask-dotnet/pull/494)) -- Add dependency injection support to durabletasktesthost by Naiyuan Tian ([#613](https://github.com/microsoft/durabletask-dotnet/pull/613)) - -## v1.19.1 -- Throw an `InvalidOperationException` for purge requests on running orchestrations by sophiatev ([#611](https://github.com/microsoft/durabletask-dotnet/pull/611)) -- Validate c# identifiers in durabletask source generator by Copilot ([#578](https://github.com/microsoft/durabletask-dotnet/pull/578)) -- Document orchestration discovery and method probing behavior in analyzers by Copilot ([#594](https://github.com/microsoft/durabletask-dotnet/pull/594)) - -## v1.19.0 -- Extended sessions for entities in .net isolated by sophiatev ([#507](https://github.com/microsoft/durabletask-dotnet/pull/507)) -- Adding the ability to specify tags and a retry policy for suborchestrations by sophiatev ([#603](https://github.com/microsoft/durabletask-dotnet/pull/603)) -- Improve durabletask source generator detection and add optional project type configuration by Copilot ([#575](https://github.com/microsoft/durabletask-dotnet/pull/575)) -- Add timeprovider support to orchestration analyzer by Copilot ([#573](https://github.com/microsoft/durabletask-dotnet/pull/573)) -- Expand azure functions smoke tests to cover source generator scenarios by Copilot ([#604](https://github.com/microsoft/durabletask-dotnet/pull/604)) -- Fix "syntaxtree is not part of the compilation" exception in orchestration analyzers by Copilot ([#588](https://github.com/microsoft/durabletask-dotnet/pull/588)) -- Add waitforexternalevent overload with timeout and cancellation token by Copilot ([#555](https://github.com/microsoft/durabletask-dotnet/pull/555)) -- Fix source generator for void-returning activity functions by Copilot ([#554](https://github.com/microsoft/durabletask-dotnet/pull/554)) - -## v1.18.2 -- Add copy constructors to TaskOptions and sub-classes by halspang ([#587](https://github.com/microsoft/durabletask-dotnet/pull/587)) -- Change FunctionNotFound analyzer severity to Info for cross-assembly scenarios by Copilot ([#584](https://github.com/microsoft/durabletask-dotnet/pull/584)) -- Add Roslyn analyzer for non-contextual logger usage in orchestrations (DURABLE0010) by Copilot ([#553](https://github.com/microsoft/durabletask-dotnet/pull/553)) -- Add specific logging categories for Worker.Grpc and orchestration logs with backward-compatible opt-in by Copilot ([#583](https://github.com/microsoft/durabletask-dotnet/pull/583)) -- Fix flaky integration test race condition in dedup status check by Copilot ([#579](https://github.com/microsoft/durabletask-dotnet/pull/579)) -- Add analyzer to suggest input parameter binding over GetInput() by Copilot ([#550](https://github.com/microsoft/durabletask-dotnet/pull/550)) -- Add strongly-typed external events with DurableEventAttribute by Copilot ([#549](https://github.com/microsoft/durabletask-dotnet/pull/549)) -- Fix orchestration analyzer to detect non-function orchestrations correctly by Copilot ([#572](https://github.com/microsoft/durabletask-dotnet/pull/572)) -- Fix race condition in WaitForInstanceAsync causing intermittent test failures by Copilot ([#574](https://github.com/microsoft/durabletask-dotnet/pull/574)) -- Add HelpLinkUri to Roslyn analyzer diagnostics by Copilot ([#548](https://github.com/microsoft/durabletask-dotnet/pull/548)) -- Add DateTimeOffset.Now and DateTimeOffset.UtcNow detection to Roslyn analyzer by Copilot ([#547](https://github.com/microsoft/durabletask-dotnet/pull/547)) -- Bump Google.Protobuf from 3.33.1 to 3.33.2 by dependabot[bot] ([#569](https://github.com/microsoft/durabletask-dotnet/pull/569)) -- Add integration test coverage for Suspend/Resume operations by Copilot ([#546](https://github.com/microsoft/durabletask-dotnet/pull/546)) -- Bump coverlet.collector from 6.0.2 to 6.0.4 by dependabot[bot] ([#527](https://github.com/microsoft/durabletask-dotnet/pull/527)) -- Bump FluentAssertions from 6.12.1 to 6.12.2 by dependabot[bot] ([#528](https://github.com/microsoft/durabletask-dotnet/pull/528)) -- Add Azure Functions smoke tests with Docker CI automation by Copilot ([#545](https://github.com/microsoft/durabletask-dotnet/pull/545)) -- Bump dotnet-sdk from 10.0.100 to 10.0.101 by dependabot[bot] ([#568](https://github.com/microsoft/durabletask-dotnet/pull/568)) -- Add scheduled auto-closure for stale "Needs Author Feedback" issues by Copilot ([#566](https://github.com/microsoft/durabletask-dotnet/pull/566)) - -## v1.18.1 -- Support dedup status when starting orchestration by wangbill ([#542](https://github.com/microsoft/durabletask-dotnet/pull/542)) -- Add 404 exception handling in blobpayloadstore.downloadasync by Copilot ([#534](https://github.com/microsoft/durabletask-dotnet/pull/534)) -- Bump analyzers version to 0.2.0 by Copilot ([#552](https://github.com/microsoft/durabletask-dotnet/pull/552)) -- Add integration tests for exception type handling by Copilot ([#544](https://github.com/microsoft/durabletask-dotnet/pull/544)) -- Add roslyn analyzer to detect calls to non-existent functions (name mismatch) by Copilot ([#530](https://github.com/microsoft/durabletask-dotnet/pull/530)) -- Remove preview suffix by Copilot ([#541](https://github.com/microsoft/durabletask-dotnet/pull/541)) -- Add xml documentation with see cref links to generated code for better ide navigation by Copilot ([#535](https://github.com/microsoft/durabletask-dotnet/pull/535)) -- Add entity source generation support for durable functions by Copilot ([#533](https://github.com/microsoft/durabletask-dotnet/pull/533)) - -## v1.18.0 -- Add taskentity support to durabletasksourcegenerator by Copilot ([#517](https://github.com/microsoft/durabletask-dotnet/pull/517)) -- Bump azure.identity by dependabot[bot] ([#525](https://github.com/microsoft/durabletask-dotnet/pull/525)) -- Bump google.protobuf by dependabot[bot] ([#529](https://github.com/microsoft/durabletask-dotnet/pull/529)) -- Configure dependabot for dotnet-sdk updates by Tomer Rosenthal ([#524](https://github.com/microsoft/durabletask-dotnet/pull/524)) -- Add code review guidelines to copilot-instructions.md by Copilot ([#522](https://github.com/microsoft/durabletask-dotnet/pull/522)) -- Remove webapi sample by sophiatev ([#520](https://github.com/microsoft/durabletask-dotnet/pull/520)) -- Fix functioncontext check and polymorphic type conversions in activity analyzer by Naiyuan Tian ([#506](https://github.com/microsoft/durabletask-dotnet/pull/506)) -- Align waitforexternalevent waiter picking order to lifo by wangbill ([#509](https://github.com/microsoft/durabletask-dotnet/pull/509)) -- Update project to support .net 6.0 alongside .net 8.0 and .net 10 by Tomer Rosenthal ([#512](https://github.com/microsoft/durabletask-dotnet/pull/512)) -- Update project to target .net 8.0 and .net 10 and upgrade dependencies by Tomer Rosenthal ([#510](https://github.com/microsoft/durabletask-dotnet/pull/510)) -- Support worker features announcement by wangbill ([#502](https://github.com/microsoft/durabletask-dotnet/pull/502)) -- Introduce custom copilot review instructions by halspang ([#503](https://github.com/microsoft/durabletask-dotnet/pull/503)) -- Add API to get orchestration history ([#516](https://github.com/microsoft/durabletask-dotnet/pull/516)) - -## v1.17.1 -- Fix Worker Registry and Add Documentation Notes by nytian in [#462](https://github.com/microsoft/durabletask-dotnet/pull/495) -- Initial attempt to fix carryover events issue on continue-as-new by cgillum in [#496](https://github.com/microsoft/durabletask-dotnet/pull/496) -- Fix encoding of entity unlock events by sebastianburckhardt in [#462](https://github.com/microsoft/durabletask-dotnet/pull/462) - -## v1.17.0 --Add Microsoft.DurableTask.Extensions.AzureBlobPayloads to nuget publish by YunchuWang in [#488](https://github.com/microsoft/durabletask-dotnet/pull/488) --Add API for In-process Testing and Add Class-Syntax Integration Tests by nytian in [#476](https://github.com/microsoft/durabletask-dotnet/pull/476) --Fix Purge Instance Comments by sophiatev in [#489](https://github.com/microsoft/durabletask-dotnet/pull/489) --Fix ServiceCollectionExtensions.AddDurableTaskClient by sophiatev in [#490](https://github.com/microsoft/durabletask-dotnet/pull/490) --Update zuremanaged sdks to official version by YunchuWang in [#493](https://github.com/microsoft/durabletask-dotnet/pull/493) --Add Rewind to .NET isolated by sophiatev in [#479](https://github.com/microsoft/durabletask-dotnet/pull/479) --Add tags field to CompleteOrchestratorAction by sophiatev in [#492](https://github.com/microsoft/durabletask-dotnet/pull/492) - -## v1.16.2 -- Generate changelog script + update changelog for v1.16.1 by wangbill ([#486](https://github.com/microsoft/durabletask-dotnet/pull/486)) -- Remove unnecessary project reference to grpc.azuremanagedbackend in azureblobpayloads.csproj by wangbill ([#485](https://github.com/microsoft/durabletask-dotnet/pull/485)) -- Large payload azure blob externalization support by wangbill ([#468](https://github.com/microsoft/durabletask-dotnet/pull/468)) - -## v1.16.1 -- Include exception properties in failure details when orchestration throws directly by Naiyuan Tian ([#482](https://github.com/microsoft/durabletask-dotnet/pull/482)) -- Set low priority for scheduled runs by Daniel Castro ([#477](https://github.com/microsoft/durabletask-dotnet/pull/477)) - -## v1.16.0 -- Include Exception Properties at FailureDetails by nytian in([#474](https://github.com/microsoft/durabletask-dotnet/pull/474)) - -## v1.15.1 -- Add version check to activities by @halspang in ([#472](https://github.com/microsoft/durabletask-dotnet/pull/472)) - -## v1.15.0 -- Abandon workitem if processing workitem failed by @YunchuWang in ([#467](https://github.com/microsoft/durabletask-dotnet/pull/467)) -- Extended Sessions for Isolated (Orchestrations) by @sophiatev in ([#449](https://github.com/microsoft/durabletask-dotnet/pull/449)) - -## v1.14.0 -- Add RestartAsync API Support at DurableTaskClient ([#456](https://github.com/microsoft/durabletask-dotnet/pull/456)) - -## v1.13.0 -- Add orchestration execution tracing ([#441](https://github.com/microsoft/durabletask-dotnet/pull/441)) - -## v1.12.0 - -- Activity tag support ([#426](https://github.com/microsoft/durabletask-dotnet/pull/426)) -- Adding Analyzer to build and release ([#444](https://github.com/microsoft/durabletask-dotnet/pull/444)) -- Add ability to filter orchestrations at worker ([#443](https://github.com/microsoft/durabletask-dotnet/pull/443)) -- Removing breaking change for TaskOptions ([#446](https://github.com/microsoft/durabletask-dotnet/pull/446)) -- Expose gRPC retry options in Azure Managed extensions ([#447](https://github.com/microsoft/durabletask-dotnet/pull/447)) - -## v1.11.0 - -- Add New Property Properties to TaskOrchestrationContext ([#415](https://github.com/microsoft/durabletask-dotnet/pull/415)) -- Add automatic retry on gateway timeout in `GrpcDurableTaskClient.WaitForInstanceCompletionAsync` ([#412](https://github.com/microsoft/durabletask-dotnet/pull/412)) -- Add specific logging for NotFound error on worker connection ([#413](https://github.com/microsoft/durabletask-dotnet/pull/413)) -- Add user agent header to gRPC called ([#417](https://github.com/microsoft/durabletask-dotnet/pull/417)) -- Enrich User-Agent Header in gRPC Metadata to indicate Client or Worker as caller ([#421](https://github.com/microsoft/durabletask-dotnet/pull/421)) -- Change DTS user agent metadata to avoid overlap with gRPC user agent ([#423](https://github.com/microsoft/durabletask-dotnet/pull/423)) -- Add extension methods for registering entities by type ([#427](https://github.com/microsoft/durabletask-dotnet/pull/427)) -- Add TaskVersion and utilize it for version overrides when starting orchestrations ([#416](https://github.com/microsoft/durabletask-dotnet/pull/416)) -- Update sub-orchestration default versioning ([#437](https://github.com/microsoft/durabletask-dotnet/pull/437)) -- Distributed Tracing for Entities (Isolated) ([#404](https://github.com/microsoft/durabletask-dotnet/pull/404)) - -## v1.10.0 - -- Update DurableTask.Core to v3.1.0 and Bump version to v1.10.0 by @nytian in ([#411](https://github.com/microsoft/durabletask-dotnet/pull/411)) - -## v1.9.1 - -- Add basic orchestration and activity execution logs by @cgillum in ([#405](https://github.com/microsoft/durabletask-dotnet/pull/405)) -- Add default version in `TaskOrchestrationContext` by @halspang in ([#408](https://github.com/microsoft/durabletask-dotnet/pull/408)) - -## v1.9.0 - -- Fix schedule sample logging setup by @YunchuWang in ([#392](https://github.com/microsoft/durabletask-dotnet/pull/392)) -- Introduce versioning to the DurableTaskClient by @halspang in ([#393](https://github.com/microsoft/durabletask-dotnet/pull/393)) -- Support for local credential types for DTS by @cgillum in ([#396](https://github.com/microsoft/durabletask-dotnet/pull/396)) -- Add utilities for easier versioning usage by @halspang in ([#394](https://github.com/microsoft/durabletask-dotnet/pull/394)) -- Add tags to CreateInstanceRequest by @torosent in ([#397](https://github.com/microsoft/durabletask-dotnet/pull/397)) -- Partial Purge Support by @YunchuWang in ([#400](https://github.com/microsoft/durabletask-dotnet/pull/400)) -- Dts Grpc client retry support by @YunchuWang in ([#403](https://github.com/microsoft/durabletask-dotnet/pull/403)) -- Introduce orchestration versioning into worker by @halspang in ([#401](https://github.com/microsoft/durabletask-dotnet/pull/401)) - -## v1.8.1 - -- Add timeout to gRPC workitem streaming ([#390](https://github.com/microsoft/durabletask-dotnet/pull/390)) - -## v1.8.0 - -- Add Schedule Support for Durable Task Framework ([#368](https://github.com/microsoft/durabletask-dotnet/pull/368)) -- Fixes and improvements - -## v1.7.0 - -- Add parent trace context information when scheduling an orchestration ([#358](https://github.com/microsoft/durabletask-dotnet/pull/358)) - -## v1.6.0 - -- Added new preview packages, `Microsoft.DurableTask.Client.AzureManaged` and `Microsoft.DurableTask.Worker.AzureManaged` -- Move to Central Package Management ([#373](https://github.com/microsoft/durabletask-dotnet/pull/373)) - -### Microsoft.DurableTask.Client - -- Add new `IDurableTaskClientBuilder AddDurableTaskClient(IServiceCollection, string?)` API - -### Microsoft.DurableTask.Worker - -- Add new `IDurableTaskWorkerBuilder AddDurableTaskWorker(IServiceCollection, string?)` API -- Add support for work item history streaming - -### Microsoft.DurableTask.Worker.Grpc - -- Provide entity support for direct grpc connections to DTS ([#369](https://github.com/microsoft/durabletask-dotnet/pull/369)) - -### Microsoft.DurableTask.Grpc - -- Replace submodule for proto files with download script for easier maintenance -- Update to latest proto files - -## v1.5.0 - -- Implement work item completion tokens for standalone worker scenarios ([#359](https://github.com/microsoft/durabletask-dotnet/pull/359)) -- Support for worker concurrency configuration ([#359](https://github.com/microsoft/durabletask-dotnet/pull/359)) -- Bump System.Text.Json to 6.0.10 -- Initial support for the Azure-managed [Durable Task Scheduler](https://techcommunity.microsoft.com/blog/appsonazureblog/announcing-limited-early-access-of-the-durable-task-scheduler-for-azure-durable-/4286526) preview. - -## v1.4.0 - -- Microsoft.Azure.DurableTask.Core dependency increased to `3.0.0` - -## v1.3.0 - -### Microsoft.DurableTask.Abstractions - -- Add `RetryPolicy.Handle` property to allow for exception filtering on retries ([#314](https://github.com/microsoft/durabletask-dotnet/pull/314)) - -## v1.2.4 - -- Microsoft.Azure.DurableTask.Core dependency increased to `2.17.1` - -## v1.2.3 - -### Microsoft.DurableTask.Client - -- Fix filter not being passed along in `PurgeAllInstancesAsync` (https://github.com/microsoft/durabletask-dotnet/pull/289) - -### Microsoft.DurableTask.Abstractions - -- Enable inner exception detail propagation in `TaskFailureDetails` ([#290](https://github.com/microsoft/durabletask-dotnet/pull/290)) -- Microsoft.Azure.DurableTask.Core dependency increased to `2.17.0` - -## v1.2.2 - -### Microsoft.DurableTask.Abstractions - -- Fix `TaskFailureDetails.IsCausedBy` to support custom exceptions and 3rd party exceptions ([#273](https://github.com/microsoft/durabletask-dotnet/pull/273)) -- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.2` - -### Microsoft.DurableTask.Client - -- Fix typo in `PurgeInstanceAsync` in `DurableTaskClient` (https://github.com/microsoft/durabletask-dotnet/pull/264) - -## v1.2.0 - -- Adds support to recursively terminate/purge sub-orchestrations in `GrpcDurableTaskClient` (https://github.com/microsoft/durabletask-dotnet/pull/262) - -## v1.1.1 - -- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.1` - -## v1.1.0 - -- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.0` - -## v1.1.0-preview.2 - -- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.0-preview.2` - -## v1.1.0-preview.1 - -Adds support for durable entities. Learn more [here](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-entities?tabs=csharp). - -### Microsoft.DurableTask.Abstractions - -- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.0-preview.1` - -## v1.0.5 - -### Microsoft.DurableTask.Abstractions - -- Microsoft.Azure.DurableTask.Core dependency increased to `2.15.0` (https://github.com/microsoft/durabletask-dotnet/pull/212) - -### Microsoft.DurableTask.Worker - -- Fix re-encoding of events when using `TaskOrchestrationContext.ContinueAsNew(preserveUnprocessedEvents: true)` (https://github.com/microsoft/durabletask-dotnet/pull/212) - -## v1.0.4 - -### Microsoft.DurableTask.Worker - -- Fix handling of concurrent external events with the same name (https://github.com/microsoft/durabletask-dotnet/pull/194) - -## v1.0.3 - -### Microsoft.DurableTask.Worker - -- Fix instance ID not being passed in when using retry policy (https://github.com/microsoft/durabletask-dotnet/issues/174) - -### Microsoft.DurableTask.Worker.Grpc - -- Add `GrpcDurableTaskWorkerOptions.CallInvoker` as an alternative to `GrpcDurableTaskWorkerOptions.Channel` - -### Microsoft.DurableTask.Client.Grpc - -- Add `GrpcDurableTaskClientOptions.CallInvoker` as an alternative to `GrpcDurableTaskClientOptions.Channel` - -## v1.0.2 - -### Microsoft.DurableTask.Worker - -- Fix issue with `TaskOrchestrationContext.Parent` not being set. - -## v1.0.1 - -### Microsoft.DurableTask.Client - -- Fix incorrect bounds check on `PurgeResult` -- Address typo for `DurableTaskClient.GetInstancesAsync` (incorrectly pluralized) - - Added `GetInstanceAsync` - - Hide `GetInstancesAsync` from editor - -## v1.0.0 - -- Added `SuspendInstanceAsync` and `ResumeInstanceAsync` to `DurableTaskClient`. -- Rename `DurableTaskClient` methods - - `TerminateAsync` -> `TerminateInstanceAsync` - - `PurgeInstanceMetadataAsync` -> `PurgeInstanceAsync` - - `PurgeInstances` -> `PurgeAllInstancesAsync` - - `GetInstanceMetadataAsync` -> `GetInstanceAsync` - - `GetInstances` -> `GetAllInstancesAsync` -- `TaskOrchestrationContext.CreateReplaySafeLogger` now creates `ILogger` directly (as opposed to wrapping an existing `ILogger`). -- Durable Functions class-based syntax now resolves `ITaskActivity` instances from `IServiceProvider`, if available there. -- `DurableTaskClient` methods have been touched up to ensure `CancellationToken` is included, as well as is the last parameter. -- Removed obsolete/unimplemented local lambda activity calls from `TaskOrchestrationContext` -- Input is now an optional parameter on `TaskOrchestrationContext.ContinueAsNew` -- Multi-target gRPC projects to now use `Grpc.Net.Client` when appropriate (.NET6.0 and up) - -*Note: `Microsoft.DurableTask.Generators` is remaining as `preview.1`.* - -## v1.0.0-rc.1 - -### Included Packages - -Microsoft.DurableTask.Abstractions \ -Microsoft.DurableTask.Client \ -Microsoft.DurableTask.Client.Grpc \ -Microsoft.DurableTask.Worker \ -Microsoft.DurableTask.Worker.Grpc \ - -_see v1.0.0-preview.1 for `Microsoft.DurableTask.Generators`_ - -### Updates - -- Refactors and splits assemblies. - - `Microsoft.DurableTask.Abstractions` - - `Microsoft.DurableTask.Generators` - - `Microsoft.DurableTask.Client` - - `Microsoft.DurableTask.Client.Grpc` - - `Microsoft.DurableTask.Worker` - - `Microsoft.DurableTask.Worker.Grpc` -- Added more API documentation -- Adds ability to perform multi-instance query -- Adds `PurgeInstancesMetadataAsync` and `PurgeInstancesAsync` support and implementation to `GrpcDurableTaskClient` -- Fix issue with mixed Newtonsoft.Json and System.Text.Json serialization. -- `IDurableTaskClientProvider` added to support multiple named clients. -- Added new options pattern for starting new and sub orchestrations. -- Overhauled builder API built on top of .NET generic host. - - Now relies on dependency injection. - - Integrates into options pattern, giving a variety of ways for user configuration. - - Builder is now re-enterable. Multiple calls to `.AddDurableTask{Worker|Client}` with the same name will yield the exact same builder instance. - -### Breaking Changes - -- `Microsoft.DurableTask.Generators` reference removed. -- Added new abstract property `TaskOrchestrationContext.ParentInstance`. -- Added new abstract method `DurableTaskClient.PurgeInstancesAsync`. -- Renamed `TaskOrchestratorBase` to `TaskOrchestrator` - - `OnRunAsync` -> `RunAsync`, forced-nullability removed. - - Nullability can be done in generic params, ie: `MyOrchestrator : TaskOrchestrator` - - Nullability is not verified at runtime by the base class, it is up to the individual orchestrator implementations to verify their own nullability. -- Renamed `TaskActivityBase` to `TaskActivity` - - `OnRun` removed. With both `OnRun` and `OnRunAsync`, there was no compiler error when you did not implement one. The remaining method is now marked `abstract` to force an implementation. Synchronous implementation can still be done via `Task.FromResult`. - - `OnRunAsync` -> `RunAsync`, forced-nullability removed. - - Nullability can be done in generic params, ie: `MyActivity : TaskActivity` - - Nullability is not verified at runtime by the base class, it is up to the individual activity implementations to verify their own nullability. -- `TaskOrchestrationContext.StartSubOrchestrationAsync` refactored: - - `instanceId` parameter removed. Can now specify it via supplying `SubOrchestrationOptions` for `TaskOptions`. -- `TaskOptions` refactored to be a record type. - - Builder removed. - - Retry provided via a property `TaskRetryOptions`, which is a pseudo union-type which can be either a `RetryPolicy` or `AsyncRetryHandler`. - - `SubOrchestrationOptions` is a derived type that can be used to specific a sub-orchestrations instanceId. - - Helper method `.WithInstanceId(string? instanceId)` added. -- `DurableTaskClient.ScheduleNewOrchestrationInstanceAsync` refactored: - - `instanceId` and `startAfter` wrapped into `StartOrchestrationOptions` object. -- Builder API completely overhauled. Now built entirely on top of the .NET generic host. - - See samples for how the new API works. - - Supports multiple workers and named-clients. -- Ability to set `TaskName.Version` removed for now. Will be added when we address versioning. -- `IDurableTaskRegistry` removed, only `DurableTaskRegistry` concrete type. - - All lambda-methods renamed to `AddActivityFunc` and `AddOrchestratorFunc`. This was to avoid ambiguous or incorrect overload resolution with the factory methods. - -## v1.0.0-preview.1 - -### Included Packages - -Microsoft.DurableTask.Generators - -### Breaking Changes - -- `Microsoft.DurableTask.Generators` is now an optional package. - - no longer automatically brought in when referencing other packages. - - To get code generation, add `` to your csproj. -- `GeneratedDurableTaskExtensions.AddAllGeneratedTasks` made `internal` (from `public`) - - This is also to avoid conflicts when multiple files have this method generated. When wanting to expose to external consumes, a new extension method can be manually authored in the desired namespace and with an appropriate name which calls this method. - -## v0.4.1-beta - -Initial public release - - - - - +# Changelog + +## Unreleased + +- Split private preview on-demand sandbox APIs into opt-in `Microsoft.DurableTask.Client.AzureManaged.Sandboxes` and `Microsoft.DurableTask.Worker.AzureManaged.Sandboxes` packages. +- Updated private preview on-demand sandbox worker profile declarations to use `SandboxWorkerProfileOptions.AddActivity(...)`, and updated the on-demand sandbox sample to share activity name constants between the main app and remote worker. +- Added SDK-side validation for private preview on-demand sandbox CPU and memory resource quantities. + + +## v1.25.0-preview.1 +- Implement per-orchestration and per-activity versioning ([#695](https://github.com/microsoft/durabletask-dotnet/pull/695)) +- Add es-metadata.yml to schema 1.0.0 ([#722](https://github.com/microsoft/durabletask-dotnet/pull/722)) +- Release v1.24.2 ([#724](https://github.com/microsoft/durabletask-dotnet/pull/724)) +- Add per-task versioning via `[DurableTask(Version = "...")]`, `TaskOptions.Version`, and `StartOrchestrationOptions.Version` ([#695](https://github.com/microsoft/durabletask-dotnet/pull/695)) + + +## v1.24.2 +- Bump DI.Abstractions and Bcl.AsyncInterfaces to 9.0.1 ([#3433](https://github.com/microsoft/durabletask-dotnet/pull/3433)) (#723) +- Validate UseWorkItemFilters names against registered tasks at worker build time ([#719](https://github.com/microsoft/durabletask-dotnet/pull/719)) +- Bump `Microsoft.Extensions.DependencyInjection.Abstractions` from 8.0.2 to 9.0.1 (and `Microsoft.Bcl.AsyncInterfaces` from 8.0.0 to 9.0.1, which the former transitively floors at 9.0.1) to align with the floor declared by `Microsoft.Azure.WebJobs 3.0.45 -> Microsoft.Extensions.Logging.Abstractions 9.0.1`. Fixes NU1605 in downstream Azure Functions Worker isolated apps consuming `Microsoft.DurableTask.Extensions.AzureBlobPayloads` ([Azure/azure-functions-durable-extension#3433](https://github.com/Azure/azure-functions-durable-extension/issues/3433)). +- Validate explicit `UseWorkItemFilters(filters)` filter names against the worker's `DurableTaskRegistry`. Filters that reference an orchestration, activity, or entity name not registered with the worker now throw `OptionsValidationException` at worker startup instead of silently waiting for work items that will never arrive. No customer-side validation call is required. ([#719](https://github.com/microsoft/durabletask-dotnet/pull/719)) + +## 1.24.1 +- Add retry to grpc calls that failed due to transient errors by @sophiatev ([#714](https://github.com/microsoft/durabletask-dotnet/pull/714)) + +## v1.24.0 +- Harden grpc worker and client against silent disconnects by @berndverst ([#708](https://github.com/microsoft/durabletask-dotnet/pull/708)) +- Preserve late events after continue-as-new by @berndverst ([#711](https://github.com/microsoft/durabletask-dotnet/pull/711)) +- Fix inprocesstesthost continueasnew stuck-instance race condition by @bachuv ([#707](https://github.com/microsoft/durabletask-dotnet/pull/707)) +- Fix continue-as-new race condition at inprocesstesthost by @nytian ([#703](https://github.com/microsoft/durabletask-dotnet/pull/703)) +- Add opt-in timeout to purgeinstancesfilter for partial purge by @yunchuwang ([#680](https://github.com/microsoft/durabletask-dotnet/pull/680)) + +## v1.23.3 +- fix: revert shared framework packages to 8.x for net8 Functions host compatibility ([#698](https://github.com/microsoft/durabletask-dotnet/pull/698)) +- Release v1.23.2 ([#693](https://github.com/microsoft/durabletask-dotnet/pull/693)) + +## v1.23.2 +- fix: improve large payload error handling — better error message and prevent infinite retry and fix conflict with auto chunking ([#691](https://github.com/microsoft/durabletask-dotnet/pull/691)) +- Bump dotnet-sdk from 10.0.103 to 10.0.201 ([#673](https://github.com/microsoft/durabletask-dotnet/pull/673)) +- Bump Microsoft.Azure.DurableTask.Core from 3.7.0 to 3.7.1 ([#685](https://github.com/microsoft/durabletask-dotnet/pull/685)) +- feat(copilot): add evidence-based Copilot customizations ([#690](https://github.com/microsoft/durabletask-dotnet/pull/690)) + +## v1.23.1 +- Fix CHANGELOG line ending preservation in Prepare Release workflow ([#687](https://github.com/microsoft/durabletask-dotnet/pull/687)) +- Add Prepare Release GitHub Action for automated release kickoff ([#686](https://github.com/microsoft/durabletask-dotnet/pull/686)) +- Add ContinueAsNewOptions with NewVersion support ([#682](https://github.com/microsoft/durabletask-dotnet/pull/682)) +- Fix concurrent timer race condition in InMemoryOrchestrationService ([#678](https://github.com/microsoft/durabletask-dotnet/pull/678)) + +## v1.23.0 +- Generate extension methods in task namespace instead of Microsoft.DurableTask ([#538](https://github.com/microsoft/durabletask-dotnet/pull/538)) +- Fix #668: Change work item filters from auto opt-in to explicit opt-in ([#669](https://github.com/microsoft/durabletask-dotnet/pull/669)) +- Add `ReplaySafeLoggerFactory` for context wrappers ([#670](https://github.com/microsoft/durabletask-dotnet/pull/670)) +- Add NuGet publish job for Microsoft.DurableTask.Analyzers ([#662](https://github.com/microsoft/durabletask-dotnet/pull/662)) +- Bump Azure.Identity from 1.17.1 to 1.18.0 ([#656](https://github.com/microsoft/durabletask-dotnet/pull/656)) +- Bump Microsoft.Azure.Functions.Worker.Extensions.DurableTask from 1.12.1 to 1.15.0 ([#658](https://github.com/microsoft/durabletask-dotnet/pull/658)) +- Add missing input validation to SuspendInstanceAsync and ResumeInstanceAsync ([#652](https://github.com/microsoft/durabletask-dotnet/pull/652)) +- Add ExportHistory package to NuGet publish pipeline ([#651](https://github.com/microsoft/durabletask-dotnet/pull/651)) +- Add OpenTelemetry sample and update deps ([#637](https://github.com/microsoft/durabletask-dotnet/pull/637)) +- Fix build warnings and clean up exception message ([#647](https://github.com/microsoft/durabletask-dotnet/pull/647)) + +## v1.22.0 +- Changing the default dedupe statuses behavior by sophiatev ([#622](https://github.com/microsoft/durabletask-dotnet/pull/622)) +- Bump Analyzers package version to 1.22.0 stable release (from 0.3.0) +- Add DURABLE0011: ContinueAsNew warning for unbounded orchestration loops ([#660](https://github.com/microsoft/durabletask-dotnet/pull/660)) + +## 1.21.0 +- Introduce WorkItemFilters into worker flow by halspang ([#616](https://github.com/microsoft/durabletask-dotnet/pull/616)) +- Fix Analyzers treating passed in variable argument name as null by wangbill ([#640](https://github.com/microsoft/durabletask-dotnet/pull/640)) +- Move DURABLE0009/0010 from Unshipped to Shipped for v0.3.0 by cgillum ([#641](https://github.com/microsoft/durabletask-dotnet/pull/641)) + +## 1.20.1 +- Fix GrpcChannel handle leak in AzureManaged backendby nytian ([#629](https://github.com/microsoft/durabletask-dotnet/pull/629)) + +## 1.20.0 +- Partial orchestration workitem completion support (merge after next dts dp release) by wangbill ([#514](https://github.com/microsoft/durabletask-dotnet/pull/514)) +- Export history job by wangbill ([#494](https://github.com/microsoft/durabletask-dotnet/pull/494)) +- Add dependency injection support to durabletasktesthost by Naiyuan Tian ([#613](https://github.com/microsoft/durabletask-dotnet/pull/613)) + +## v1.19.1 +- Throw an `InvalidOperationException` for purge requests on running orchestrations by sophiatev ([#611](https://github.com/microsoft/durabletask-dotnet/pull/611)) +- Validate c# identifiers in durabletask source generator by Copilot ([#578](https://github.com/microsoft/durabletask-dotnet/pull/578)) +- Document orchestration discovery and method probing behavior in analyzers by Copilot ([#594](https://github.com/microsoft/durabletask-dotnet/pull/594)) + +## v1.19.0 +- Extended sessions for entities in .net isolated by sophiatev ([#507](https://github.com/microsoft/durabletask-dotnet/pull/507)) +- Adding the ability to specify tags and a retry policy for suborchestrations by sophiatev ([#603](https://github.com/microsoft/durabletask-dotnet/pull/603)) +- Improve durabletask source generator detection and add optional project type configuration by Copilot ([#575](https://github.com/microsoft/durabletask-dotnet/pull/575)) +- Add timeprovider support to orchestration analyzer by Copilot ([#573](https://github.com/microsoft/durabletask-dotnet/pull/573)) +- Expand azure functions smoke tests to cover source generator scenarios by Copilot ([#604](https://github.com/microsoft/durabletask-dotnet/pull/604)) +- Fix "syntaxtree is not part of the compilation" exception in orchestration analyzers by Copilot ([#588](https://github.com/microsoft/durabletask-dotnet/pull/588)) +- Add waitforexternalevent overload with timeout and cancellation token by Copilot ([#555](https://github.com/microsoft/durabletask-dotnet/pull/555)) +- Fix source generator for void-returning activity functions by Copilot ([#554](https://github.com/microsoft/durabletask-dotnet/pull/554)) + +## v1.18.2 +- Add copy constructors to TaskOptions and sub-classes by halspang ([#587](https://github.com/microsoft/durabletask-dotnet/pull/587)) +- Change FunctionNotFound analyzer severity to Info for cross-assembly scenarios by Copilot ([#584](https://github.com/microsoft/durabletask-dotnet/pull/584)) +- Add Roslyn analyzer for non-contextual logger usage in orchestrations (DURABLE0010) by Copilot ([#553](https://github.com/microsoft/durabletask-dotnet/pull/553)) +- Add specific logging categories for Worker.Grpc and orchestration logs with backward-compatible opt-in by Copilot ([#583](https://github.com/microsoft/durabletask-dotnet/pull/583)) +- Fix flaky integration test race condition in dedup status check by Copilot ([#579](https://github.com/microsoft/durabletask-dotnet/pull/579)) +- Add analyzer to suggest input parameter binding over GetInput() by Copilot ([#550](https://github.com/microsoft/durabletask-dotnet/pull/550)) +- Add strongly-typed external events with DurableEventAttribute by Copilot ([#549](https://github.com/microsoft/durabletask-dotnet/pull/549)) +- Fix orchestration analyzer to detect non-function orchestrations correctly by Copilot ([#572](https://github.com/microsoft/durabletask-dotnet/pull/572)) +- Fix race condition in WaitForInstanceAsync causing intermittent test failures by Copilot ([#574](https://github.com/microsoft/durabletask-dotnet/pull/574)) +- Add HelpLinkUri to Roslyn analyzer diagnostics by Copilot ([#548](https://github.com/microsoft/durabletask-dotnet/pull/548)) +- Add DateTimeOffset.Now and DateTimeOffset.UtcNow detection to Roslyn analyzer by Copilot ([#547](https://github.com/microsoft/durabletask-dotnet/pull/547)) +- Bump Google.Protobuf from 3.33.1 to 3.33.2 by dependabot[bot] ([#569](https://github.com/microsoft/durabletask-dotnet/pull/569)) +- Add integration test coverage for Suspend/Resume operations by Copilot ([#546](https://github.com/microsoft/durabletask-dotnet/pull/546)) +- Bump coverlet.collector from 6.0.2 to 6.0.4 by dependabot[bot] ([#527](https://github.com/microsoft/durabletask-dotnet/pull/527)) +- Bump FluentAssertions from 6.12.1 to 6.12.2 by dependabot[bot] ([#528](https://github.com/microsoft/durabletask-dotnet/pull/528)) +- Add Azure Functions smoke tests with Docker CI automation by Copilot ([#545](https://github.com/microsoft/durabletask-dotnet/pull/545)) +- Bump dotnet-sdk from 10.0.100 to 10.0.101 by dependabot[bot] ([#568](https://github.com/microsoft/durabletask-dotnet/pull/568)) +- Add scheduled auto-closure for stale "Needs Author Feedback" issues by Copilot ([#566](https://github.com/microsoft/durabletask-dotnet/pull/566)) + +## v1.18.1 +- Support dedup status when starting orchestration by wangbill ([#542](https://github.com/microsoft/durabletask-dotnet/pull/542)) +- Add 404 exception handling in blobpayloadstore.downloadasync by Copilot ([#534](https://github.com/microsoft/durabletask-dotnet/pull/534)) +- Bump analyzers version to 0.2.0 by Copilot ([#552](https://github.com/microsoft/durabletask-dotnet/pull/552)) +- Add integration tests for exception type handling by Copilot ([#544](https://github.com/microsoft/durabletask-dotnet/pull/544)) +- Add roslyn analyzer to detect calls to non-existent functions (name mismatch) by Copilot ([#530](https://github.com/microsoft/durabletask-dotnet/pull/530)) +- Remove preview suffix by Copilot ([#541](https://github.com/microsoft/durabletask-dotnet/pull/541)) +- Add xml documentation with see cref links to generated code for better ide navigation by Copilot ([#535](https://github.com/microsoft/durabletask-dotnet/pull/535)) +- Add entity source generation support for durable functions by Copilot ([#533](https://github.com/microsoft/durabletask-dotnet/pull/533)) + +## v1.18.0 +- Add taskentity support to durabletasksourcegenerator by Copilot ([#517](https://github.com/microsoft/durabletask-dotnet/pull/517)) +- Bump azure.identity by dependabot[bot] ([#525](https://github.com/microsoft/durabletask-dotnet/pull/525)) +- Bump google.protobuf by dependabot[bot] ([#529](https://github.com/microsoft/durabletask-dotnet/pull/529)) +- Configure dependabot for dotnet-sdk updates by Tomer Rosenthal ([#524](https://github.com/microsoft/durabletask-dotnet/pull/524)) +- Add code review guidelines to copilot-instructions.md by Copilot ([#522](https://github.com/microsoft/durabletask-dotnet/pull/522)) +- Remove webapi sample by sophiatev ([#520](https://github.com/microsoft/durabletask-dotnet/pull/520)) +- Fix functioncontext check and polymorphic type conversions in activity analyzer by Naiyuan Tian ([#506](https://github.com/microsoft/durabletask-dotnet/pull/506)) +- Align waitforexternalevent waiter picking order to lifo by wangbill ([#509](https://github.com/microsoft/durabletask-dotnet/pull/509)) +- Update project to support .net 6.0 alongside .net 8.0 and .net 10 by Tomer Rosenthal ([#512](https://github.com/microsoft/durabletask-dotnet/pull/512)) +- Update project to target .net 8.0 and .net 10 and upgrade dependencies by Tomer Rosenthal ([#510](https://github.com/microsoft/durabletask-dotnet/pull/510)) +- Support worker features announcement by wangbill ([#502](https://github.com/microsoft/durabletask-dotnet/pull/502)) +- Introduce custom copilot review instructions by halspang ([#503](https://github.com/microsoft/durabletask-dotnet/pull/503)) +- Add API to get orchestration history ([#516](https://github.com/microsoft/durabletask-dotnet/pull/516)) + +## v1.17.1 +- Fix Worker Registry and Add Documentation Notes by nytian in [#462](https://github.com/microsoft/durabletask-dotnet/pull/495) +- Initial attempt to fix carryover events issue on continue-as-new by cgillum in [#496](https://github.com/microsoft/durabletask-dotnet/pull/496) +- Fix encoding of entity unlock events by sebastianburckhardt in [#462](https://github.com/microsoft/durabletask-dotnet/pull/462) + +## v1.17.0 +-Add Microsoft.DurableTask.Extensions.AzureBlobPayloads to nuget publish by YunchuWang in [#488](https://github.com/microsoft/durabletask-dotnet/pull/488) +-Add API for In-process Testing and Add Class-Syntax Integration Tests by nytian in [#476](https://github.com/microsoft/durabletask-dotnet/pull/476) +-Fix Purge Instance Comments by sophiatev in [#489](https://github.com/microsoft/durabletask-dotnet/pull/489) +-Fix ServiceCollectionExtensions.AddDurableTaskClient by sophiatev in [#490](https://github.com/microsoft/durabletask-dotnet/pull/490) +-Update zuremanaged sdks to official version by YunchuWang in [#493](https://github.com/microsoft/durabletask-dotnet/pull/493) +-Add Rewind to .NET isolated by sophiatev in [#479](https://github.com/microsoft/durabletask-dotnet/pull/479) +-Add tags field to CompleteOrchestratorAction by sophiatev in [#492](https://github.com/microsoft/durabletask-dotnet/pull/492) + +## v1.16.2 +- Generate changelog script + update changelog for v1.16.1 by wangbill ([#486](https://github.com/microsoft/durabletask-dotnet/pull/486)) +- Remove unnecessary project reference to grpc.azuremanagedbackend in azureblobpayloads.csproj by wangbill ([#485](https://github.com/microsoft/durabletask-dotnet/pull/485)) +- Large payload azure blob externalization support by wangbill ([#468](https://github.com/microsoft/durabletask-dotnet/pull/468)) + +## v1.16.1 +- Include exception properties in failure details when orchestration throws directly by Naiyuan Tian ([#482](https://github.com/microsoft/durabletask-dotnet/pull/482)) +- Set low priority for scheduled runs by Daniel Castro ([#477](https://github.com/microsoft/durabletask-dotnet/pull/477)) + +## v1.16.0 +- Include Exception Properties at FailureDetails by nytian in([#474](https://github.com/microsoft/durabletask-dotnet/pull/474)) + +## v1.15.1 +- Add version check to activities by @halspang in ([#472](https://github.com/microsoft/durabletask-dotnet/pull/472)) + +## v1.15.0 +- Abandon workitem if processing workitem failed by @YunchuWang in ([#467](https://github.com/microsoft/durabletask-dotnet/pull/467)) +- Extended Sessions for Isolated (Orchestrations) by @sophiatev in ([#449](https://github.com/microsoft/durabletask-dotnet/pull/449)) + +## v1.14.0 +- Add RestartAsync API Support at DurableTaskClient ([#456](https://github.com/microsoft/durabletask-dotnet/pull/456)) + +## v1.13.0 +- Add orchestration execution tracing ([#441](https://github.com/microsoft/durabletask-dotnet/pull/441)) + +## v1.12.0 + +- Activity tag support ([#426](https://github.com/microsoft/durabletask-dotnet/pull/426)) +- Adding Analyzer to build and release ([#444](https://github.com/microsoft/durabletask-dotnet/pull/444)) +- Add ability to filter orchestrations at worker ([#443](https://github.com/microsoft/durabletask-dotnet/pull/443)) +- Removing breaking change for TaskOptions ([#446](https://github.com/microsoft/durabletask-dotnet/pull/446)) +- Expose gRPC retry options in Azure Managed extensions ([#447](https://github.com/microsoft/durabletask-dotnet/pull/447)) + +## v1.11.0 + +- Add New Property Properties to TaskOrchestrationContext ([#415](https://github.com/microsoft/durabletask-dotnet/pull/415)) +- Add automatic retry on gateway timeout in `GrpcDurableTaskClient.WaitForInstanceCompletionAsync` ([#412](https://github.com/microsoft/durabletask-dotnet/pull/412)) +- Add specific logging for NotFound error on worker connection ([#413](https://github.com/microsoft/durabletask-dotnet/pull/413)) +- Add user agent header to gRPC called ([#417](https://github.com/microsoft/durabletask-dotnet/pull/417)) +- Enrich User-Agent Header in gRPC Metadata to indicate Client or Worker as caller ([#421](https://github.com/microsoft/durabletask-dotnet/pull/421)) +- Change DTS user agent metadata to avoid overlap with gRPC user agent ([#423](https://github.com/microsoft/durabletask-dotnet/pull/423)) +- Add extension methods for registering entities by type ([#427](https://github.com/microsoft/durabletask-dotnet/pull/427)) +- Add TaskVersion and utilize it for version overrides when starting orchestrations ([#416](https://github.com/microsoft/durabletask-dotnet/pull/416)) +- Update sub-orchestration default versioning ([#437](https://github.com/microsoft/durabletask-dotnet/pull/437)) +- Distributed Tracing for Entities (Isolated) ([#404](https://github.com/microsoft/durabletask-dotnet/pull/404)) + +## v1.10.0 + +- Update DurableTask.Core to v3.1.0 and Bump version to v1.10.0 by @nytian in ([#411](https://github.com/microsoft/durabletask-dotnet/pull/411)) + +## v1.9.1 + +- Add basic orchestration and activity execution logs by @cgillum in ([#405](https://github.com/microsoft/durabletask-dotnet/pull/405)) +- Add default version in `TaskOrchestrationContext` by @halspang in ([#408](https://github.com/microsoft/durabletask-dotnet/pull/408)) + +## v1.9.0 + +- Fix schedule sample logging setup by @YunchuWang in ([#392](https://github.com/microsoft/durabletask-dotnet/pull/392)) +- Introduce versioning to the DurableTaskClient by @halspang in ([#393](https://github.com/microsoft/durabletask-dotnet/pull/393)) +- Support for local credential types for DTS by @cgillum in ([#396](https://github.com/microsoft/durabletask-dotnet/pull/396)) +- Add utilities for easier versioning usage by @halspang in ([#394](https://github.com/microsoft/durabletask-dotnet/pull/394)) +- Add tags to CreateInstanceRequest by @torosent in ([#397](https://github.com/microsoft/durabletask-dotnet/pull/397)) +- Partial Purge Support by @YunchuWang in ([#400](https://github.com/microsoft/durabletask-dotnet/pull/400)) +- Dts Grpc client retry support by @YunchuWang in ([#403](https://github.com/microsoft/durabletask-dotnet/pull/403)) +- Introduce orchestration versioning into worker by @halspang in ([#401](https://github.com/microsoft/durabletask-dotnet/pull/401)) + +## v1.8.1 + +- Add timeout to gRPC workitem streaming ([#390](https://github.com/microsoft/durabletask-dotnet/pull/390)) + +## v1.8.0 + +- Add Schedule Support for Durable Task Framework ([#368](https://github.com/microsoft/durabletask-dotnet/pull/368)) +- Fixes and improvements + +## v1.7.0 + +- Add parent trace context information when scheduling an orchestration ([#358](https://github.com/microsoft/durabletask-dotnet/pull/358)) + +## v1.6.0 + +- Added new preview packages, `Microsoft.DurableTask.Client.AzureManaged` and `Microsoft.DurableTask.Worker.AzureManaged` +- Move to Central Package Management ([#373](https://github.com/microsoft/durabletask-dotnet/pull/373)) + +### Microsoft.DurableTask.Client + +- Add new `IDurableTaskClientBuilder AddDurableTaskClient(IServiceCollection, string?)` API + +### Microsoft.DurableTask.Worker + +- Add new `IDurableTaskWorkerBuilder AddDurableTaskWorker(IServiceCollection, string?)` API +- Add support for work item history streaming + +### Microsoft.DurableTask.Worker.Grpc + +- Provide entity support for direct grpc connections to DTS ([#369](https://github.com/microsoft/durabletask-dotnet/pull/369)) + +### Microsoft.DurableTask.Grpc + +- Replace submodule for proto files with download script for easier maintenance +- Update to latest proto files + +## v1.5.0 + +- Implement work item completion tokens for standalone worker scenarios ([#359](https://github.com/microsoft/durabletask-dotnet/pull/359)) +- Support for worker concurrency configuration ([#359](https://github.com/microsoft/durabletask-dotnet/pull/359)) +- Bump System.Text.Json to 6.0.10 +- Initial support for the Azure-managed [Durable Task Scheduler](https://techcommunity.microsoft.com/blog/appsonazureblog/announcing-limited-early-access-of-the-durable-task-scheduler-for-azure-durable-/4286526) preview. + +## v1.4.0 + +- Microsoft.Azure.DurableTask.Core dependency increased to `3.0.0` + +## v1.3.0 + +### Microsoft.DurableTask.Abstractions + +- Add `RetryPolicy.Handle` property to allow for exception filtering on retries ([#314](https://github.com/microsoft/durabletask-dotnet/pull/314)) + +## v1.2.4 + +- Microsoft.Azure.DurableTask.Core dependency increased to `2.17.1` + +## v1.2.3 + +### Microsoft.DurableTask.Client + +- Fix filter not being passed along in `PurgeAllInstancesAsync` (https://github.com/microsoft/durabletask-dotnet/pull/289) + +### Microsoft.DurableTask.Abstractions + +- Enable inner exception detail propagation in `TaskFailureDetails` ([#290](https://github.com/microsoft/durabletask-dotnet/pull/290)) +- Microsoft.Azure.DurableTask.Core dependency increased to `2.17.0` + +## v1.2.2 + +### Microsoft.DurableTask.Abstractions + +- Fix `TaskFailureDetails.IsCausedBy` to support custom exceptions and 3rd party exceptions ([#273](https://github.com/microsoft/durabletask-dotnet/pull/273)) +- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.2` + +### Microsoft.DurableTask.Client + +- Fix typo in `PurgeInstanceAsync` in `DurableTaskClient` (https://github.com/microsoft/durabletask-dotnet/pull/264) + +## v1.2.0 + +- Adds support to recursively terminate/purge sub-orchestrations in `GrpcDurableTaskClient` (https://github.com/microsoft/durabletask-dotnet/pull/262) + +## v1.1.1 + +- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.1` + +## v1.1.0 + +- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.0` + +## v1.1.0-preview.2 + +- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.0-preview.2` + +## v1.1.0-preview.1 + +Adds support for durable entities. Learn more [here](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-entities?tabs=csharp). + +### Microsoft.DurableTask.Abstractions + +- Microsoft.Azure.DurableTask.Core dependency increased to `2.16.0-preview.1` + +## v1.0.5 + +### Microsoft.DurableTask.Abstractions + +- Microsoft.Azure.DurableTask.Core dependency increased to `2.15.0` (https://github.com/microsoft/durabletask-dotnet/pull/212) + +### Microsoft.DurableTask.Worker + +- Fix re-encoding of events when using `TaskOrchestrationContext.ContinueAsNew(preserveUnprocessedEvents: true)` (https://github.com/microsoft/durabletask-dotnet/pull/212) + +## v1.0.4 + +### Microsoft.DurableTask.Worker + +- Fix handling of concurrent external events with the same name (https://github.com/microsoft/durabletask-dotnet/pull/194) + +## v1.0.3 + +### Microsoft.DurableTask.Worker + +- Fix instance ID not being passed in when using retry policy (https://github.com/microsoft/durabletask-dotnet/issues/174) + +### Microsoft.DurableTask.Worker.Grpc + +- Add `GrpcDurableTaskWorkerOptions.CallInvoker` as an alternative to `GrpcDurableTaskWorkerOptions.Channel` + +### Microsoft.DurableTask.Client.Grpc + +- Add `GrpcDurableTaskClientOptions.CallInvoker` as an alternative to `GrpcDurableTaskClientOptions.Channel` + +## v1.0.2 + +### Microsoft.DurableTask.Worker + +- Fix issue with `TaskOrchestrationContext.Parent` not being set. + +## v1.0.1 + +### Microsoft.DurableTask.Client + +- Fix incorrect bounds check on `PurgeResult` +- Address typo for `DurableTaskClient.GetInstancesAsync` (incorrectly pluralized) + - Added `GetInstanceAsync` + - Hide `GetInstancesAsync` from editor + +## v1.0.0 + +- Added `SuspendInstanceAsync` and `ResumeInstanceAsync` to `DurableTaskClient`. +- Rename `DurableTaskClient` methods + - `TerminateAsync` -> `TerminateInstanceAsync` + - `PurgeInstanceMetadataAsync` -> `PurgeInstanceAsync` + - `PurgeInstances` -> `PurgeAllInstancesAsync` + - `GetInstanceMetadataAsync` -> `GetInstanceAsync` + - `GetInstances` -> `GetAllInstancesAsync` +- `TaskOrchestrationContext.CreateReplaySafeLogger` now creates `ILogger` directly (as opposed to wrapping an existing `ILogger`). +- Durable Functions class-based syntax now resolves `ITaskActivity` instances from `IServiceProvider`, if available there. +- `DurableTaskClient` methods have been touched up to ensure `CancellationToken` is included, as well as is the last parameter. +- Removed obsolete/unimplemented local lambda activity calls from `TaskOrchestrationContext` +- Input is now an optional parameter on `TaskOrchestrationContext.ContinueAsNew` +- Multi-target gRPC projects to now use `Grpc.Net.Client` when appropriate (.NET6.0 and up) + +*Note: `Microsoft.DurableTask.Generators` is remaining as `preview.1`.* + +## v1.0.0-rc.1 + +### Included Packages + +Microsoft.DurableTask.Abstractions \ +Microsoft.DurableTask.Client \ +Microsoft.DurableTask.Client.Grpc \ +Microsoft.DurableTask.Worker \ +Microsoft.DurableTask.Worker.Grpc \ + +_see v1.0.0-preview.1 for `Microsoft.DurableTask.Generators`_ + +### Updates + +- Refactors and splits assemblies. + - `Microsoft.DurableTask.Abstractions` + - `Microsoft.DurableTask.Generators` + - `Microsoft.DurableTask.Client` + - `Microsoft.DurableTask.Client.Grpc` + - `Microsoft.DurableTask.Worker` + - `Microsoft.DurableTask.Worker.Grpc` +- Added more API documentation +- Adds ability to perform multi-instance query +- Adds `PurgeInstancesMetadataAsync` and `PurgeInstancesAsync` support and implementation to `GrpcDurableTaskClient` +- Fix issue with mixed Newtonsoft.Json and System.Text.Json serialization. +- `IDurableTaskClientProvider` added to support multiple named clients. +- Added new options pattern for starting new and sub orchestrations. +- Overhauled builder API built on top of .NET generic host. + - Now relies on dependency injection. + - Integrates into options pattern, giving a variety of ways for user configuration. + - Builder is now re-enterable. Multiple calls to `.AddDurableTask{Worker|Client}` with the same name will yield the exact same builder instance. + +### Breaking Changes + +- `Microsoft.DurableTask.Generators` reference removed. +- Added new abstract property `TaskOrchestrationContext.ParentInstance`. +- Added new abstract method `DurableTaskClient.PurgeInstancesAsync`. +- Renamed `TaskOrchestratorBase` to `TaskOrchestrator` + - `OnRunAsync` -> `RunAsync`, forced-nullability removed. + - Nullability can be done in generic params, ie: `MyOrchestrator : TaskOrchestrator` + - Nullability is not verified at runtime by the base class, it is up to the individual orchestrator implementations to verify their own nullability. +- Renamed `TaskActivityBase` to `TaskActivity` + - `OnRun` removed. With both `OnRun` and `OnRunAsync`, there was no compiler error when you did not implement one. The remaining method is now marked `abstract` to force an implementation. Synchronous implementation can still be done via `Task.FromResult`. + - `OnRunAsync` -> `RunAsync`, forced-nullability removed. + - Nullability can be done in generic params, ie: `MyActivity : TaskActivity` + - Nullability is not verified at runtime by the base class, it is up to the individual activity implementations to verify their own nullability. +- `TaskOrchestrationContext.StartSubOrchestrationAsync` refactored: + - `instanceId` parameter removed. Can now specify it via supplying `SubOrchestrationOptions` for `TaskOptions`. +- `TaskOptions` refactored to be a record type. + - Builder removed. + - Retry provided via a property `TaskRetryOptions`, which is a pseudo union-type which can be either a `RetryPolicy` or `AsyncRetryHandler`. + - `SubOrchestrationOptions` is a derived type that can be used to specific a sub-orchestrations instanceId. + - Helper method `.WithInstanceId(string? instanceId)` added. +- `DurableTaskClient.ScheduleNewOrchestrationInstanceAsync` refactored: + - `instanceId` and `startAfter` wrapped into `StartOrchestrationOptions` object. +- Builder API completely overhauled. Now built entirely on top of the .NET generic host. + - See samples for how the new API works. + - Supports multiple workers and named-clients. +- Ability to set `TaskName.Version` removed for now. Will be added when we address versioning. +- `IDurableTaskRegistry` removed, only `DurableTaskRegistry` concrete type. + - All lambda-methods renamed to `AddActivityFunc` and `AddOrchestratorFunc`. This was to avoid ambiguous or incorrect overload resolution with the factory methods. + +## v1.0.0-preview.1 + +### Included Packages + +Microsoft.DurableTask.Generators + +### Breaking Changes + +- `Microsoft.DurableTask.Generators` is now an optional package. + - no longer automatically brought in when referencing other packages. + - To get code generation, add `` to your csproj. +- `GeneratedDurableTaskExtensions.AddAllGeneratedTasks` made `internal` (from `public`) + - This is also to avoid conflicts when multiple files have this method generated. When wanting to expose to external consumes, a new extension method can be manually authored in the desired namespace and with an appropriate name which calls this method. + +## v0.4.1-beta + +Initial public release + + + + + diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index 0b05c418..3c6d4539 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.3.32901.215 @@ -117,16 +117,34 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ReplaySafeLoggerFactorySamp EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkerVersioningSample", "samples\WorkerVersioningSample\WorkerVersioningSample.csproj", "{26988639-D204-4E0B-80BE-F4E11952DFF8}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{D4587EC0-1B16-8420-7502-A967139249D4}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{53193780-CD18-2643-6953-C26F59EAEDF5}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EternalOrchestrationVersionMigrationSample", "samples\EternalOrchestrationVersionMigrationSample\EternalOrchestrationVersionMigrationSample.csproj", "{1E30F09F-1ADA-4375-81CC-F0FBC74D5621}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActivityVersioningSample", "samples\ActivityVersioningSample\ActivityVersioningSample.csproj", "{3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityWithVersionedOrchestrationSample", "samples\EntityWithVersionedOrchestrationSample\EntityWithVersionedOrchestrationSample.csproj", "{8E0D27B3-2B5D-4B6F-A4E6-5C8E7B0F7DD2}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "on-demand-sandbox", "on-demand-sandbox", "{D422B5FD-8E3C-6588-ACD1-DEFAB429269C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "shared", "samples\on-demand-sandbox\shared\shared.csproj", "{F0DC6D16-C9BC-4804-BBAF-84C050D5E279}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "main-app", "samples\on-demand-sandbox\main-app\main-app.csproj", "{0CB44F0A-3483-4052-A49F-D4E6F140741C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "remote-worker", "samples\on-demand-sandbox\remote-worker\remote-worker.csproj", "{B7069604-DD97-4115-8B30-FC1D4C0E6D43}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged.Sandboxes", "AzureManaged.Sandboxes", "{28648169-70E4-D0BA-4357-338A556A7DA8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client.AzureManaged.Sandboxes", "src\Client\AzureManaged.Sandboxes\Client.AzureManaged.Sandboxes.csproj", "{A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{D4587EC0-1B16-8420-7502-A967139249D4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged.Sandboxes", "AzureManaged.Sandboxes", "{9686B8F9-2644-6C9B-E567-55B0471E4584}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Worker.AzureManaged.Sandboxes", "src\Worker\AzureManaged.Sandboxes\Worker.AzureManaged.Sandboxes.csproj", "{C1995163-1DCE-405D-BE82-8B4B2584893E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureManaged", "AzureManaged", "{53193780-CD18-2643-6953-C26F59EAEDF5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Grpc", "Grpc", "{3B8F957E-7773-4C0C-ACD7-91A1591D9312}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -761,6 +779,66 @@ Global {8E0D27B3-2B5D-4B6F-A4E6-5C8E7B0F7DD2}.Release|x64.Build.0 = Release|Any CPU {8E0D27B3-2B5D-4B6F-A4E6-5C8E7B0F7DD2}.Release|x86.ActiveCfg = Release|Any CPU {8E0D27B3-2B5D-4B6F-A4E6-5C8E7B0F7DD2}.Release|x86.Build.0 = Release|Any CPU + {F0DC6D16-C9BC-4804-BBAF-84C050D5E279}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F0DC6D16-C9BC-4804-BBAF-84C050D5E279}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0DC6D16-C9BC-4804-BBAF-84C050D5E279}.Debug|x64.ActiveCfg = Debug|Any CPU + {F0DC6D16-C9BC-4804-BBAF-84C050D5E279}.Debug|x64.Build.0 = Debug|Any CPU + {F0DC6D16-C9BC-4804-BBAF-84C050D5E279}.Debug|x86.ActiveCfg = Debug|Any CPU + {F0DC6D16-C9BC-4804-BBAF-84C050D5E279}.Debug|x86.Build.0 = Debug|Any CPU + {F0DC6D16-C9BC-4804-BBAF-84C050D5E279}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F0DC6D16-C9BC-4804-BBAF-84C050D5E279}.Release|Any CPU.Build.0 = Release|Any CPU + {F0DC6D16-C9BC-4804-BBAF-84C050D5E279}.Release|x64.ActiveCfg = Release|Any CPU + {F0DC6D16-C9BC-4804-BBAF-84C050D5E279}.Release|x64.Build.0 = Release|Any CPU + {F0DC6D16-C9BC-4804-BBAF-84C050D5E279}.Release|x86.ActiveCfg = Release|Any CPU + {F0DC6D16-C9BC-4804-BBAF-84C050D5E279}.Release|x86.Build.0 = Release|Any CPU + {0CB44F0A-3483-4052-A49F-D4E6F140741C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0CB44F0A-3483-4052-A49F-D4E6F140741C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0CB44F0A-3483-4052-A49F-D4E6F140741C}.Debug|x64.ActiveCfg = Debug|Any CPU + {0CB44F0A-3483-4052-A49F-D4E6F140741C}.Debug|x64.Build.0 = Debug|Any CPU + {0CB44F0A-3483-4052-A49F-D4E6F140741C}.Debug|x86.ActiveCfg = Debug|Any CPU + {0CB44F0A-3483-4052-A49F-D4E6F140741C}.Debug|x86.Build.0 = Debug|Any CPU + {0CB44F0A-3483-4052-A49F-D4E6F140741C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0CB44F0A-3483-4052-A49F-D4E6F140741C}.Release|Any CPU.Build.0 = Release|Any CPU + {0CB44F0A-3483-4052-A49F-D4E6F140741C}.Release|x64.ActiveCfg = Release|Any CPU + {0CB44F0A-3483-4052-A49F-D4E6F140741C}.Release|x64.Build.0 = Release|Any CPU + {0CB44F0A-3483-4052-A49F-D4E6F140741C}.Release|x86.ActiveCfg = Release|Any CPU + {0CB44F0A-3483-4052-A49F-D4E6F140741C}.Release|x86.Build.0 = Release|Any CPU + {B7069604-DD97-4115-8B30-FC1D4C0E6D43}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B7069604-DD97-4115-8B30-FC1D4C0E6D43}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7069604-DD97-4115-8B30-FC1D4C0E6D43}.Debug|x64.ActiveCfg = Debug|Any CPU + {B7069604-DD97-4115-8B30-FC1D4C0E6D43}.Debug|x64.Build.0 = Debug|Any CPU + {B7069604-DD97-4115-8B30-FC1D4C0E6D43}.Debug|x86.ActiveCfg = Debug|Any CPU + {B7069604-DD97-4115-8B30-FC1D4C0E6D43}.Debug|x86.Build.0 = Debug|Any CPU + {B7069604-DD97-4115-8B30-FC1D4C0E6D43}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B7069604-DD97-4115-8B30-FC1D4C0E6D43}.Release|Any CPU.Build.0 = Release|Any CPU + {B7069604-DD97-4115-8B30-FC1D4C0E6D43}.Release|x64.ActiveCfg = Release|Any CPU + {B7069604-DD97-4115-8B30-FC1D4C0E6D43}.Release|x64.Build.0 = Release|Any CPU + {B7069604-DD97-4115-8B30-FC1D4C0E6D43}.Release|x86.ActiveCfg = Release|Any CPU + {B7069604-DD97-4115-8B30-FC1D4C0E6D43}.Release|x86.Build.0 = Release|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Debug|x64.ActiveCfg = Debug|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Debug|x64.Build.0 = Debug|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Debug|x86.ActiveCfg = Debug|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Debug|x86.Build.0 = Debug|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Release|Any CPU.Build.0 = Release|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Release|x64.ActiveCfg = Release|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Release|x64.Build.0 = Release|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Release|x86.ActiveCfg = Release|Any CPU + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056}.Release|x86.Build.0 = Release|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Debug|x64.ActiveCfg = Debug|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Debug|x64.Build.0 = Debug|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Debug|x86.ActiveCfg = Debug|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Debug|x86.Build.0 = Debug|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Release|Any CPU.Build.0 = Release|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Release|x64.ActiveCfg = Release|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Release|x64.Build.0 = Release|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Release|x86.ActiveCfg = Release|Any CPU + {C1995163-1DCE-405D-BE82-8B4B2584893E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -819,11 +897,20 @@ Global {5A69FD28-D814-490E-A76B-B0A5F88C25B2} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {8E7BECBC-7226-4778-B8F2-8EBDFF0D3BA4} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {26988639-D204-4E0B-80BE-F4E11952DFF8} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} - {D4587EC0-1B16-8420-7502-A967139249D4} = {1C217BB2-CE16-41CC-9D47-0FC0DB60BDB3} - {53193780-CD18-2643-6953-C26F59EAEDF5} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5} {1E30F09F-1ADA-4375-81CC-F0FBC74D5621} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {3FBCFDBA-F547-4FD5-B8C6-0B645EF73E3A} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {8E0D27B3-2B5D-4B6F-A4E6-5C8E7B0F7DD2} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} + {D422B5FD-8E3C-6588-ACD1-DEFAB429269C} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} + {F0DC6D16-C9BC-4804-BBAF-84C050D5E279} = {D422B5FD-8E3C-6588-ACD1-DEFAB429269C} + {0CB44F0A-3483-4052-A49F-D4E6F140741C} = {D422B5FD-8E3C-6588-ACD1-DEFAB429269C} + {B7069604-DD97-4115-8B30-FC1D4C0E6D43} = {D422B5FD-8E3C-6588-ACD1-DEFAB429269C} + {28648169-70E4-D0BA-4357-338A556A7DA8} = {1C217BB2-CE16-41CC-9D47-0FC0DB60BDB3} + {A7C6DF09-1AD3-42F6-BCF1-D40E011D1056} = {28648169-70E4-D0BA-4357-338A556A7DA8} + {D4587EC0-1B16-8420-7502-A967139249D4} = {1C217BB2-CE16-41CC-9D47-0FC0DB60BDB3} + {9686B8F9-2644-6C9B-E567-55B0471E4584} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5} + {C1995163-1DCE-405D-BE82-8B4B2584893E} = {9686B8F9-2644-6C9B-E567-55B0471E4584} + {53193780-CD18-2643-6953-C26F59EAEDF5} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5} + {3B8F957E-7773-4C0C-ACD7-91A1591D9312} = {5B448FF6-EC42-491D-A22E-1DC8B618E6D5} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71} diff --git a/README.md b/README.md index 84686d37..2fc37547 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Durable Task .NET Client SDK +# Durable Task .NET Client SDK [![Build status](https://github.com/microsoft/durabletask-dotnet/workflows/Validate%20Build/badge.svg)](https://github.com/microsoft/durabletask-dotnet/actions?workflow=Validate+Build) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) @@ -198,6 +198,8 @@ This SDK can also be used with the Durable Task Scheduler directly, without any For runnable DTS emulator examples that demonstrate versioning, see the [WorkerVersioningSample](samples/WorkerVersioningSample/README.md) (deployment-based versioning), the [EternalOrchestrationVersionMigrationSample](samples/EternalOrchestrationVersionMigrationSample/README.md) (multi-version routing with `[DurableTask(Version = "...")]`), the [ActivityVersioningSample](samples/ActivityVersioningSample/README.md) (activity versioning with inherited defaults and explicit override support), and the [EntityWithVersionedOrchestrationSample](samples/EntityWithVersionedOrchestrationSample/README.md) (a single instance migrating v1→v2 via `ContinueAsNew(NewVersion)` while preserving entity-held state). +The [on-demand sandbox activities sample](samples/on-demand-sandbox/README.md) shows how to declare selected activities for Durable Task Scheduler (DTS)-managed on-demand sandbox execution and build the remote worker container image separately from the declarer app. + ## Obtaining the Protobuf definitions 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/eng/publish/publish.yml b/eng/publish/publish.yml index b77ead2d..a3009469 100644 --- a/eng/publish/publish.yml +++ b/eng/publish/publish.yml @@ -137,7 +137,7 @@ extends: # the packages to push pattern explicitly excludes: # - 'Microsoft.DurableTask.Client.Grpc' # - 'Microsoft.DurableTask.Client.OrchestrationServiceClientShim' - # - 'Microsoft.DurableTask.Client.AzureManaged' + # - 'Microsoft.DurableTask.Client.AzureManaged.*' # which are pushed by their respective jobs packagesToPush: '$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Client.*.nupkg;!$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Client.Grpc.*.nupkg;!$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Client.OrchestrationServiceClientShim.*.nupkg;!$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Client.AzureManaged.*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' # Despite this being a custom command, we need to keep this for 1ES validation packageParentPath: $(System.DefaultWorkingDirectory) # This needs to be set to some prefix of the `packagesToPush` parameter. Apparently it helps with SDL tooling @@ -324,7 +324,30 @@ extends: command: push nuGetFeedType: external publishFeedCredentials: 'DurableTask org NuGet API Key' - packagesToPush: '$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Client.AzureManaged.*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' # Despite this being a custom command, we need to keep this for 1ES validation + packagesToPush: '$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Client.AzureManaged.*.nupkg;!$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Client.AzureManaged.Sandboxes.*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' # Despite this being a custom command, we need to keep this for 1ES validation + packageParentPath: $(System.DefaultWorkingDirectory) # This needs to be set to some prefix of the `packagesToPush` parameter. Apparently it helps with SDL tooling + + # NuGet release (Microsoft.DurableTask.Client.AzureManaged.Sandboxes) + - job: nugetRelease_Microsoft_DurableTask_Client_AzureManaged_Sandboxes + displayName: NuGet Release (Microsoft.DurableTask.Client.AzureManaged.Sandboxes) + dependsOn: nugetApproval + condition: succeeded('nugetApproval') # nuget packages need to be on ADO first + templateContext: + type: releaseJob + isProduction: true + inputs: + - input: pipelineArtifact + pipeline: officialPipeline # Pipeline reference as defined in the resources section + artifactName: drop + targetPath: $(System.DefaultWorkingDirectory)/drop + steps: + - task: 1ES.PublishNuget@1 + displayName: 'NuGet push (Microsoft.DurableTask.Client.AzureManaged.Sandboxes)' + inputs: + command: push + nuGetFeedType: external + publishFeedCredentials: 'DurableTask org NuGet API Key' + packagesToPush: '$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Client.AzureManaged.Sandboxes.*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' # Despite this being a custom command, we need to keep this for 1ES validation packageParentPath: $(System.DefaultWorkingDirectory) # This needs to be set to some prefix of the `packagesToPush` parameter. Apparently it helps with SDL tooling # NuGet release (Microsoft.DurableTask.Worker.AzureManaged) @@ -347,7 +370,30 @@ extends: command: push nuGetFeedType: external publishFeedCredentials: 'DurableTask org NuGet API Key' - packagesToPush: '$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Worker.AzureManaged.*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' # Despite this being a custom command, we need to keep this for 1ES validation + packagesToPush: '$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Worker.AzureManaged.*.nupkg;!$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Worker.AzureManaged.Sandboxes.*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' # Despite this being a custom command, we need to keep this for 1ES validation + packageParentPath: $(System.DefaultWorkingDirectory) # This needs to be set to some prefix of the `packagesToPush` parameter. Apparently it helps with SDL tooling + + # NuGet release (Microsoft.DurableTask.Worker.AzureManaged.Sandboxes) + - job: nugetRelease_Microsoft_DurableTask_Worker_AzureManaged_Sandboxes + displayName: NuGet Release (Microsoft.DurableTask.Worker.AzureManaged.Sandboxes) + dependsOn: nugetApproval + condition: succeeded('nugetApproval') # nuget packages need to be on ADO first + templateContext: + type: releaseJob + isProduction: true + inputs: + - input: pipelineArtifact + pipeline: officialPipeline # Pipeline reference as defined in the resources section + artifactName: drop + targetPath: $(System.DefaultWorkingDirectory)/drop + steps: + - task: 1ES.PublishNuget@1 + displayName: 'NuGet push (Microsoft.DurableTask.Worker.AzureManaged.Sandboxes)' + inputs: + command: push + nuGetFeedType: external + publishFeedCredentials: 'DurableTask org NuGet API Key' + packagesToPush: '$(System.DefaultWorkingDirectory)/drop/Microsoft.DurableTask.Worker.AzureManaged.Sandboxes.*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' # Despite this being a custom command, we need to keep this for 1ES validation packageParentPath: $(System.DefaultWorkingDirectory) # This needs to be set to some prefix of the `packagesToPush` parameter. Apparently it helps with SDL tooling # NuGet release (Microsoft.DurableTask.ScheduledTasks) diff --git a/samples/on-demand-sandbox/README.md b/samples/on-demand-sandbox/README.md new file mode 100644 index 00000000..134182df --- /dev/null +++ b/samples/on-demand-sandbox/README.md @@ -0,0 +1,74 @@ +# On-demand sandbox activities sample + +This sample shows how to run selected Durable Task activities in DTS-managed on-demand sandboxes. + +The sample is intentionally split into two projects: + +| Path | Purpose | +| --- | --- | +| `shared/` | Defines activity identity constants shared by the main app and remote worker. | +| `main-app/` | Runs locally or in a normal app host. It declares the on-demand sandbox activity and starts one hello orchestration. | +| `remote-worker/` | Builds the container image that DTS starts inside a sandbox. It contains the remote hello activity. | + +## Build + +```powershell +dotnet build .\samples\on-demand-sandbox\main-app\main-app.csproj +dotnet build .\samples\on-demand-sandbox\remote-worker\remote-worker.csproj +``` + +## Build the remote worker image + +Run from the repository root: + +```powershell +$image = ".azurecr.io/dts-ondemand-sandbox-sample:" +docker build -f .\samples\on-demand-sandbox\remote-worker\Containerfile -t $image . +docker push $image +``` + +## Run a hello orchestration + +The main app uses `DefaultAzureCredential`; sign in with Azure CLI or configure another supported Azure identity before running it. +After pushing the remote worker image, set these environment variables: + +```powershell +$env:DTS_SANDBOX_CONTAINER_IMAGE = "" +$env:DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID = "" +$env:DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID = "" +``` + +The worker profile class declares the image, CPU, memory, max concurrency, and on-demand sandbox activity identities with `options.AddActivity(name, version)`. The main app and remote worker both use the `shared/SandboxActivities.cs` constants so the workerProfile and worker registration stay in sync. + +Update `main-app/appsettings.json` with your scheduler endpoint and task hub: + +```json +{ + "OnDemandSandboxSample": { + "EndpointAddress": "https://", + "TaskHubName": "" + } +} +``` + +Then run the main app: + +```powershell +Push-Location .\samples\on-demand-sandbox\main-app +dotnet run +Pop-Location +``` + +Expected output includes the on-demand sandbox activity result: + +```text +Runtime status: Completed +Output: "hello locally: on-demand-sandbox-sample; hello remotely from pid=: on-demand-sandbox-sample" +``` + +Use the Durable Task Scheduler dashboard's On-demand sandbox preview tab to inspect sandboxes and stream runtime logs. + +The remote worker image does not need customer-provided DTS runtime settings. +DTS injects the scheduler endpoint, task hub, worker profile, capacity, sandbox provider, +and sandbox identifier when it starts the sandbox. The worker reports the +activity identities registered in the image when it connects. diff --git a/samples/on-demand-sandbox/main-app/Activities.cs b/samples/on-demand-sandbox/main-app/Activities.cs new file mode 100644 index 00000000..4048e3ea --- /dev/null +++ b/samples/on-demand-sandbox/main-app/Activities.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask; + +namespace Microsoft.DurableTask.Samples.OnDemandSandbox.MainApp; + +internal static class OnDemandSandboxTaskNames +{ + public const string LocalHello = "LocalHello"; + public const string HelloOrchestrator = nameof(HelloOrchestrator); +} + +[DurableTask(OnDemandSandboxTaskNames.LocalHello)] +internal sealed class LocalHelloActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, string input) + => Task.FromResult($"hello locally: {input}"); +} + diff --git a/samples/on-demand-sandbox/main-app/Orchestrators.cs b/samples/on-demand-sandbox/main-app/Orchestrators.cs new file mode 100644 index 00000000..332a69b0 --- /dev/null +++ b/samples/on-demand-sandbox/main-app/Orchestrators.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask; +using Microsoft.DurableTask.Samples.OnDemandSandbox.Shared; + +namespace Microsoft.DurableTask.Samples.OnDemandSandbox.MainApp; + +[DurableTask(nameof(HelloOrchestrator))] +internal sealed class HelloOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string input) + { + string localResult = await context.CallActivityAsync(OnDemandSandboxTaskNames.LocalHello, input); + string remoteResult = await context.CallActivityAsync( + SandboxActivities.RemoteHelloName, + input, + new TaskOptions { Version = new TaskVersion(SandboxActivities.RemoteHelloVersion) }); + return $"{localResult}; {remoteResult}"; + } +} diff --git a/samples/on-demand-sandbox/main-app/Program.cs b/samples/on-demand-sandbox/main-app/Program.cs new file mode 100644 index 00000000..1e9d92e2 --- /dev/null +++ b/samples/on-demand-sandbox/main-app/Program.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Identity; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Samples.OnDemandSandbox.MainApp; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +const string Input = "on-demand-sandbox-sample"; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +string endpoint = GetRequiredConfigurationValue("OnDemandSandboxSample:EndpointAddress"); +string taskHub = GetRequiredConfigurationValue("OnDemandSandboxSample:TaskHubName"); +TokenCredential credential = new DefaultAzureCredential(); +builder.Logging.AddSimpleConsole(options => +{ + options.SingleLine = true; + options.UseUtcTimestamp = true; + options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ "; +}); + +builder.Services.AddDurableTaskWorker(workerBuilder => +{ + workerBuilder.AddTasks(tasks => tasks.AddAllGeneratedTasks()); + workerBuilder.UseWorkItemFilters(); + workerBuilder.UseDurableTaskScheduler(options => + { + options.EndpointAddress = endpoint; + options.TaskHubName = taskHub; + options.Credential = credential; + }); +}); + +builder.Services.AddDurableTaskClient(clientBuilder => +{ + clientBuilder.UseDurableTaskScheduler(options => + { + options.EndpointAddress = endpoint; + options.TaskHubName = taskHub; + options.Credential = credential; + }); +}); +builder.Services.AddDurableTaskSchedulerSandboxActivitiesClient(); + +using IHost host = builder.Build(); + +await host.StartAsync(); + +SandboxActivitiesClient sandboxActivitiesClient = host.Services.GetRequiredService(); +await sandboxActivitiesClient.EnableSandboxActivitiesAsync(); + +DurableTaskClient client = host.Services.GetRequiredService(); +string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( + OnDemandSandboxTaskNames.HelloOrchestrator, + input: Input); +Console.WriteLine($"Started orchestration: {instanceId}"); + +OrchestrationMetadata result = await client.WaitForInstanceCompletionAsync( + instanceId, + getInputsAndOutputs: true); +Console.WriteLine($"Runtime status: {result.RuntimeStatus}"); +Console.WriteLine($"Output: {result.SerializedOutput ?? ""}"); + +await host.StopAsync(); + +string GetRequiredConfigurationValue(string key) +{ + string? value = builder.Configuration[key]; + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException($"Configuration value '{key}' must be set."); + } + + return value.Trim(); +} diff --git a/samples/on-demand-sandbox/main-app/WorkerProfiles.cs b/samples/on-demand-sandbox/main-app/WorkerProfiles.cs new file mode 100644 index 00000000..1a7d96c8 --- /dev/null +++ b/samples/on-demand-sandbox/main-app/WorkerProfiles.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Samples.OnDemandSandbox.Shared; + +namespace Microsoft.DurableTask.Samples.OnDemandSandbox.MainApp; + +[SandboxWorkerProfile("remote-hello-profile")] +internal sealed class RemoteHelloSandboxWorkerProfile : ISandboxWorkerProfile +{ + public void Configure(SandboxWorkerProfileOptions options) + { + options.ContainerImage = GetRequiredEnvironmentVariable("DTS_SANDBOX_CONTAINER_IMAGE"); + options.ImagePullManagedIdentityClientId = GetRequiredEnvironmentVariable("DTS_SANDBOX_IMAGE_PULL_UMI_CLIENT_ID"); + options.SchedulerManagedIdentityClientId = GetRequiredEnvironmentVariable("DTS_SANDBOX_SCHEDULER_UMI_CLIENT_ID"); + options.Cpu = "1000m"; + options.Memory = "2048Mi"; + options.MaxConcurrentActivities = 1; + AddEnvironmentVariable(options, "SAMPLE_REMOTE_MARKER"); + AddEnvironmentVariable(options, "SAMPLE_REMOTE_DELAY_MS"); + options.AddActivity(SandboxActivities.RemoteHelloName, SandboxActivities.RemoteHelloVersion); + } + + static void AddEnvironmentVariable(SandboxWorkerProfileOptions options, string name) + { + string? value = Environment.GetEnvironmentVariable(name); + if (!string.IsNullOrWhiteSpace(value)) + { + options.EnvironmentVariables[name] = value; + } + } + + static string GetRequiredEnvironmentVariable(string name) + { + string? value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException($"{name} must be set."); + } + + return value.Trim(); + } +} diff --git a/samples/on-demand-sandbox/main-app/appsettings.json b/samples/on-demand-sandbox/main-app/appsettings.json new file mode 100644 index 00000000..89e35344 --- /dev/null +++ b/samples/on-demand-sandbox/main-app/appsettings.json @@ -0,0 +1,6 @@ +{ + "OnDemandSandboxSample": { + "EndpointAddress": "https://", + "TaskHubName": "" + } +} diff --git a/samples/on-demand-sandbox/main-app/main-app.csproj b/samples/on-demand-sandbox/main-app/main-app.csproj new file mode 100644 index 00000000..86df35d7 --- /dev/null +++ b/samples/on-demand-sandbox/main-app/main-app.csproj @@ -0,0 +1,26 @@ + + + + Exe + net10.0 + enable + OnDemandSandboxMainApp + Microsoft.DurableTask.Samples.OnDemandSandbox.MainApp + + + + + + + + + + + + + + + + + + diff --git a/samples/on-demand-sandbox/remote-worker/Activities.cs b/samples/on-demand-sandbox/remote-worker/Activities.cs new file mode 100644 index 00000000..5ac7dcf4 --- /dev/null +++ b/samples/on-demand-sandbox/remote-worker/Activities.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask; +using Microsoft.DurableTask.Samples.OnDemandSandbox.Shared; + +namespace Microsoft.DurableTask.Samples.OnDemandSandbox.RemoteWorker; + +[DurableTask(SandboxActivities.RemoteHelloName, Version = SandboxActivities.RemoteHelloVersion)] +internal sealed class RemoteHelloActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, string input) + { + return Task.FromResult($"hello remotely from {Environment.MachineName} pid={Environment.ProcessId}: {input}"); + } +} diff --git a/samples/on-demand-sandbox/remote-worker/Containerfile b/samples/on-demand-sandbox/remote-worker/Containerfile new file mode 100644 index 00000000..99267aa4 --- /dev/null +++ b/samples/on-demand-sandbox/remote-worker/Containerfile @@ -0,0 +1,30 @@ +# 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/on-demand-sandbox/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/runtime:10.0 AS runtime +WORKDIR /app + +COPY --from=build /app/publish ./ + +ENTRYPOINT ["dotnet", "OnDemandSandboxRemoteWorker.dll"] diff --git a/samples/on-demand-sandbox/remote-worker/Containerfile.dockerignore b/samples/on-demand-sandbox/remote-worker/Containerfile.dockerignore new file mode 100644 index 00000000..7def663b --- /dev/null +++ b/samples/on-demand-sandbox/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/on-demand-sandbox/ +!samples/on-demand-sandbox/** +**/bin/ +**/obj/ +**/.git/ +**/.tunnel-url \ No newline at end of file diff --git a/samples/on-demand-sandbox/remote-worker/Program.cs b/samples/on-demand-sandbox/remote-worker/Program.cs new file mode 100644 index 00000000..ef12fa29 --- /dev/null +++ b/samples/on-demand-sandbox/remote-worker/Program.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Samples.OnDemandSandbox.RemoteWorker; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +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(); + }); + workerBuilder.UseSandboxWorker(); +}); + +await builder.Build().RunAsync(); diff --git a/samples/on-demand-sandbox/remote-worker/remote-worker.csproj b/samples/on-demand-sandbox/remote-worker/remote-worker.csproj new file mode 100644 index 00000000..9a9a6aca --- /dev/null +++ b/samples/on-demand-sandbox/remote-worker/remote-worker.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + OnDemandSandboxRemoteWorker + Microsoft.DurableTask.Samples.OnDemandSandbox.RemoteWorker + + + + + + + + + + + + + + diff --git a/samples/on-demand-sandbox/shared/SandboxActivities.cs b/samples/on-demand-sandbox/shared/SandboxActivities.cs new file mode 100644 index 00000000..a073967f --- /dev/null +++ b/samples/on-demand-sandbox/shared/SandboxActivities.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Samples.OnDemandSandbox.Shared; + +public static class SandboxActivities +{ + public const string RemoteHelloName = "RemoteHello"; + + public const string RemoteHelloVersion = ""; +} \ No newline at end of file diff --git a/samples/on-demand-sandbox/shared/shared.csproj b/samples/on-demand-sandbox/shared/shared.csproj new file mode 100644 index 00000000..4767e89c --- /dev/null +++ b/samples/on-demand-sandbox/shared/shared.csproj @@ -0,0 +1,10 @@ + + + + net10.0 + enable + OnDemandSandboxShared + Microsoft.DurableTask.Samples.OnDemandSandbox.Shared + + + diff --git a/src/Client/AzureManaged.Sandboxes/Client.AzureManaged.Sandboxes.csproj b/src/Client/AzureManaged.Sandboxes/Client.AzureManaged.Sandboxes.csproj new file mode 100644 index 00000000..d25d0412 --- /dev/null +++ b/src/Client/AzureManaged.Sandboxes/Client.AzureManaged.Sandboxes.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + Azure Managed on-demand sandbox activity management extensions for the Durable Task Framework client. + Microsoft.DurableTask.Client.AzureManaged.Sandboxes + true + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Client/AzureManaged.Sandboxes/ISandboxWorkerProfile.cs b/src/Client/AzureManaged.Sandboxes/ISandboxWorkerProfile.cs new file mode 100644 index 00000000..aabd9d9a --- /dev/null +++ b/src/Client/AzureManaged.Sandboxes/ISandboxWorkerProfile.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Client.AzureManaged; + +/// +/// Configures an on-demand sandbox worker profile workerProfile. +/// +public interface ISandboxWorkerProfile +{ + /// + /// Configures the on-demand sandbox worker profile workerProfile options. + /// + /// The workerProfile options to configure. + void Configure(SandboxWorkerProfileOptions options); +} diff --git a/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClient.cs b/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClient.cs new file mode 100644 index 00000000..0432dc52 --- /dev/null +++ b/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClient.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.AzureManaged.Internal; +using Proto = Microsoft.DurableTask.Protobuf.Sandboxes; + +namespace Microsoft.DurableTask.Client.AzureManaged; + +/// +/// Client for DTS on-demand sandbox activity management operations. +/// +public sealed class SandboxActivitiesClient +{ + readonly ISandboxActivitiesTransport transport; + readonly SandboxWorkerProfileProvider workerProfileProvider; + readonly string taskHub; + + /// + /// Initializes a new instance of the class. + /// + /// The transport used to call DTS on-demand sandbox management operations. + /// The task hub whose workerProfiles should be sent to DTS. + /// The workerProfile provider. + internal SandboxActivitiesClient( + ISandboxActivitiesTransport transport, + string taskHub, + SandboxWorkerProfileProvider workerProfileProvider) + { + this.transport = Check.NotNull(transport); + this.workerProfileProvider = Check.NotNull(workerProfileProvider); + this.taskHub = string.IsNullOrWhiteSpace(taskHub) + ? throw new ArgumentException("Task hub name is required.", nameof(taskHub)) + : taskHub.Trim(); + } + + /// + /// Enables on-demand sandbox activities declared by worker profiles for the configured task hub. + /// + /// The cancellation token used to cancel the request. + /// A task that completes when DTS accepts all workerProfiles. + public async Task EnableSandboxActivitiesAsync(CancellationToken cancellation = default) + { + IReadOnlyList workerProfiles = this.workerProfileProvider.ResolveWorkerProfiles(this.taskHub); + foreach (SandboxWorkerProfileOptions options in workerProfiles) + { + SandboxActivityMetadata.Activity[] activities = SandboxWorkerProfileBuilder.ResolveActivities(options.Activities); + if (activities.Length == 0) + { + continue; + } + + Proto.SandboxWorkerProfile workerProfile = + SandboxWorkerProfileBuilder.BuildWorkerProfile(options, activities); + await this.transport.DeclareSandboxWorkerProfileAsync( + workerProfile, + this.taskHub, + cancellation) + .ConfigureAwait(false); + } + } + + /// + /// Removes an on-demand sandbox activity workerProfile for a worker profile. + /// + /// The worker profile ID whose workerProfile should be removed. + /// The cancellation token used to cancel the request. + /// A task that completes when DTS removes the workerProfile. + public Task RemoveSandboxWorkerProfileAsync( + string workerProfileId, + CancellationToken cancellation = default) + { + string normalizedWorkerProfileId = string.IsNullOrWhiteSpace(workerProfileId) + ? throw new ArgumentException("Worker profile ID is required.", nameof(workerProfileId)) + : workerProfileId.Trim(); + + return this.transport.RemoveSandboxWorkerProfileAsync( + normalizedWorkerProfileId, + this.taskHub, + cancellation); + } +} diff --git a/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs b/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs new file mode 100644 index 00000000..138e37f1 --- /dev/null +++ b/src/Client/AzureManaged.Sandboxes/SandboxActivitiesClientServiceCollectionExtensions.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Grpc.Net.Client; +using Microsoft.DurableTask.AzureManaged.Internal; +using Microsoft.DurableTask.Client.Grpc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Proto = Microsoft.DurableTask.Protobuf.Sandboxes; + +namespace Microsoft.DurableTask.Client.AzureManaged; + +/// +/// Extension methods for registering DTS on-demand sandbox activity management clients. +/// +public static class SandboxActivitiesClientServiceCollectionExtensions +{ + /// + /// Adds a DTS on-demand sandbox activity management client using the default Durable Task client configuration. + /// + /// The service collection to configure. + /// The original service collection, for call chaining. + public static IServiceCollection AddDurableTaskSchedulerSandboxActivitiesClient(this IServiceCollection services) + => AddDurableTaskSchedulerSandboxActivitiesClient(services, Options.DefaultName); + + /// + /// Adds a DTS on-demand sandbox activity management client using a named Durable Task client configuration. + /// + /// The service collection to configure. + /// The Durable Task client name whose scheduler channel should be reused. + /// The original service collection, for call chaining. + public static IServiceCollection AddDurableTaskSchedulerSandboxActivitiesClient( + this IServiceCollection services, + string clientName) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(clientName); + + services.TryAddSingleton(); + + services.AddSingleton(provider => + { + DurableTaskSchedulerClientOptions schedulerOptions = provider + .GetRequiredService>() + .Get(clientName); + GrpcDurableTaskClientOptions options = provider + .GetRequiredService>() + .Get(clientName); + SandboxWorkerProfileProvider workerProfileProvider = provider.GetRequiredService(); + + if (options.CallInvoker is { } callInvoker) + { + return new SandboxActivitiesClient( + new SandboxActivitiesGrpcTransport(new Proto.SandboxActivities.SandboxActivitiesClient(callInvoker)), + schedulerOptions.TaskHubName, + workerProfileProvider); + } + + if (options.Channel is GrpcChannel channel) + { + return new SandboxActivitiesClient( + new SandboxActivitiesGrpcTransport( + new Proto.SandboxActivities.SandboxActivitiesClient(channel.CreateCallInvoker()), + attachTaskHubMetadata: false), + schedulerOptions.TaskHubName, + workerProfileProvider); + } + + throw new InvalidOperationException("DTS on-demand sandbox activity management requires a configured Durable Task Scheduler client."); + }); + return services; + } +} diff --git a/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileAttribute.cs b/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileAttribute.cs new file mode 100644 index 00000000..30844afa --- /dev/null +++ b/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileAttribute.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Client.AzureManaged; + +/// +/// Declares an on-demand sandbox worker profile that DTS can start for activities declared by the profile. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class SandboxWorkerProfileAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The worker profile ID. + public SandboxWorkerProfileAttribute(string workerProfileId) + { + this.WorkerProfileId = string.IsNullOrWhiteSpace(workerProfileId) + ? throw new ArgumentException("On-demand sandbox worker profile ID cannot be empty.", nameof(workerProfileId)) + : workerProfileId.Trim(); + } + + /// + /// Gets the worker profile ID. + /// + public string WorkerProfileId { get; } +} diff --git a/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileBuilder.cs b/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileBuilder.cs new file mode 100644 index 00000000..465d75b8 --- /dev/null +++ b/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileBuilder.cs @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Globalization; +using Microsoft.DurableTask.AzureManaged.Internal; +using Proto = Microsoft.DurableTask.Protobuf.Sandboxes; + +namespace Microsoft.DurableTask.Client.AzureManaged; + +/// +/// Builds and normalizes on-demand sandbox activity workerProfile protocol messages. +/// +static class SandboxWorkerProfileBuilder +{ + /// + /// Resolves configured activity identities for on-demand sandbox activity execution. + /// + /// The configured activity identities. + /// The normalized activity identities. + public static SandboxActivityMetadata.Activity[] ResolveActivities(IEnumerable configuredActivities) + { + return SandboxActivityMetadata.ResolveActivities(configuredActivities); + } + + /// + /// Builds an on-demand sandbox activity workerProfile protocol message. + /// + /// The on-demand sandbox options. + /// The activity identities included in the workerProfile. + /// The workerProfile protocol message. + public static Proto.SandboxWorkerProfile BuildWorkerProfile( + SandboxWorkerProfileOptions options, + IReadOnlyCollection activities) + { + Check.NotNull(options); + Check.NotNull(activities); + + _ = NormalizeRequired(options.TaskHub, "On-demand sandbox activity workerProfile requires a task hub name."); + if (activities.Count == 0) + { + throw new InvalidOperationException("On-demand sandbox activity workerProfile requires at least one activity."); + } + + string workerProfileId = NormalizeWorkerProfileId( + options.WorkerProfileId, + "On-demand sandbox activity workerProfile requires a worker profile ID."); + if (options.MaxConcurrentActivities <= 0) + { + throw new InvalidOperationException("On-demand sandbox activity max concurrent activities must be greater than zero."); + } + + string schedulerManagedIdentityClientId = NormalizeRequired( + options.SchedulerManagedIdentityClientId ?? string.Empty, + "On-demand sandbox activity workerProfile requires the managed identity client ID workers use to connect to the DTS scheduler."); + + Proto.SandboxWorkerProfile workerProfile = new() + { + WorkerProfileId = workerProfileId, + Image = BuildImage(options), + Resources = BuildResources(options), + MaxConcurrentActivities = options.MaxConcurrentActivities, + SchedulerManagedIdentityClientId = schedulerManagedIdentityClientId, + }; + + workerProfile.Activities.AddRange(activities.Select(ToProtoActivity)); + workerProfile.EnvironmentVariables.Add(options.EnvironmentVariables); + workerProfile.Image.Entrypoint.AddRange(NormalizeOptionalStrings(options.Entrypoint)); + workerProfile.Image.Cmd.AddRange(NormalizeOptionalStrings(options.Cmd)); + return workerProfile; + } + + /// + /// Normalizes a worker profile ID and throws with the supplied message if it is missing. + /// + /// The worker profile ID value. + /// The error message to use when the value is missing. + /// The normalized worker profile ID. + internal static string NormalizeWorkerProfileId(string value, string errorMessage) + { + return SandboxActivityMetadata.NormalizeRequired(value, errorMessage); + } + + /// + /// Normalizes a required string and throws with the supplied message if it is missing. + /// + /// The value. + /// The error message to use when the value is missing. + /// The normalized value. + internal static string NormalizeRequired(string value, string errorMessage) + { + return SandboxActivityMetadata.NormalizeRequired(value, errorMessage); + } + + static Proto.SandboxActivityImage BuildImage(SandboxWorkerProfileOptions options) + { + string imageRef = NormalizeRequired( + options.ContainerImage ?? string.Empty, + "On-demand sandbox activity image metadata requires a container image reference like 'myregistry.azurecr.io/workers/hello:1.0' or 'myregistry.azurecr.io/workers/hello@sha256:...'."); + + Proto.SandboxActivityImage image = new() + { + ImageRef = imageRef, + ManagedIdentityClientId = NormalizeRequired( + options.ImagePullManagedIdentityClientId ?? string.Empty, + "On-demand sandbox activity workerProfile requires the managed identity client ID ADC uses to pull the worker image."), + }; + + return image; + } + + static Proto.SandboxActivity ToProtoActivity(SandboxActivityMetadata.Activity activity) => new() + { + Name = activity.Name, + Version = activity.Version ?? string.Empty, + }; + + static Proto.SandboxActivityResources BuildResources(SandboxWorkerProfileOptions options) + { + string cpu = NormalizeCpu(options.Cpu); + string memory = NormalizeMemory(options.Memory); + + return new Proto.SandboxActivityResources + { + Cpu = cpu, + Memory = memory, + }; + } + + static string NormalizeCpu(string value) + { + string normalized = NormalizeRequired(value, "On-demand sandbox activity workerProfile requires CPU resources."); + if (TryParseCpuMillicores(normalized) is not { } milliCpu || milliCpu <= 0) + { + throw new InvalidOperationException( + "On-demand sandbox activity CPU resources must be a positive Kubernetes-style CPU quantity. " + + "Use formats like '500m', '2', or '0.5'."); + } + + return normalized; + } + + static string NormalizeMemory(string value) + { + string normalized = NormalizeRequired(value, "On-demand sandbox activity workerProfile requires memory resources."); + if (TryParseMemoryMiB(normalized) is not { } memoryMiB || memoryMiB <= 0) + { + throw new InvalidOperationException( + "On-demand sandbox activity memory resources must be a positive Kubernetes-style memory quantity. " + + "Use formats like '256Mi', '1Gi', or '2048'."); + } + + return normalized; + } + + static long? TryParseCpuMillicores(string value) + { + if (value.EndsWith('m') || value.EndsWith('M')) + { + return long.TryParse( + value[..^1], + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out long milliCpu) + ? milliCpu + : null; + } + + return decimal.TryParse( + value, + NumberStyles.Number, + CultureInfo.InvariantCulture, + out decimal cores) + ? (long)(cores * 1000) + : null; + } + + static long? TryParseMemoryMiB(string value) + { + if (value.EndsWith("Gi", StringComparison.OrdinalIgnoreCase)) + { + return decimal.TryParse( + value[..^2], + NumberStyles.Number, + CultureInfo.InvariantCulture, + out decimal gib) + ? (long)(gib * 1024) + : null; + } + + if (value.EndsWith("Mi", StringComparison.OrdinalIgnoreCase)) + { + return decimal.TryParse( + value[..^2], + NumberStyles.Number, + CultureInfo.InvariantCulture, + out decimal mib) + ? (long)mib + : null; + } + + return decimal.TryParse( + value, + NumberStyles.Number, + CultureInfo.InvariantCulture, + out decimal parsed) + ? (long)parsed + : null; + } + + static string[] NormalizeOptionalStrings(IEnumerable values) + { + return values + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value.Trim()) + .ToArray(); + } +} diff --git a/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileOptions.cs b/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileOptions.cs new file mode 100644 index 00000000..c5ecac6f --- /dev/null +++ b/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileOptions.cs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using Microsoft.DurableTask; +using Microsoft.DurableTask.AzureManaged.Internal; + +namespace Microsoft.DurableTask.Client.AzureManaged; + +/// +/// Options for declaring on-demand sandbox activities and the worker image DTS should start for them. +/// +public sealed class SandboxWorkerProfileOptions +{ + /// + /// Gets or sets the task hub where the on-demand sandbox activity workerProfile is stored. + /// + public string TaskHub { get; set; } = string.Empty; + + /// + /// Gets or sets the worker profile ID used for the on-demand sandbox activity pool. + /// This value must be set explicitly. + /// + public string WorkerProfileId { get; set; } = string.Empty; + + /// + /// Gets or sets the full OCI container image reference for on-demand sandbox workers. + /// Examples: myregistry.azurecr.io/workers/hello:1.0 or + /// myregistry.azurecr.io/workers/hello@sha256:0123456789abcdef.... + /// + public string? ContainerImage { get; set; } + + /// + /// Gets or sets the user-assigned managed identity client ID ADC uses to pull the on-demand sandbox worker image. + /// + public string? ImagePullManagedIdentityClientId { get; set; } + + /// + /// Gets or sets the user-assigned managed identity client ID workers use to authenticate to the DTS scheduler. + /// + public string? SchedulerManagedIdentityClientId { get; set; } + + /// + /// Gets or sets the CPU quantity declared for each sandbox. Supported formats include 500m, 2, and 0.5. + /// + public string Cpu { get; set; } = "1000m"; + + /// + /// Gets or sets the memory quantity declared for each sandbox. Supported formats include 256Mi, 1Gi, and 2048. + /// + public string Memory { get; set; } = "2048Mi"; + + /// + /// Gets custom environment variables DTS should provide to on-demand sandbox workers created from this workerProfile. + /// DTS-owned runtime variables such as DTS_ENDPOINT, DTS_TASK_HUB, and + /// DTS_SANDBOX_ID are injected by the backend and should not be supplied here. + /// + public IDictionary EnvironmentVariables { get; } = new Dictionary(StringComparer.Ordinal); + + /// + /// Gets the sandbox entrypoint declared for on-demand sandbox workers. + /// + public IList Entrypoint { get; } = new List(); + + /// + /// Gets the sandbox command declared for on-demand sandbox workers. + /// + public IList Cmd { get; } = new List(); + + /// + /// Gets or sets the maximum number of concurrent activities expected from each on-demand sandbox worker. + /// + public int MaxConcurrentActivities { get; set; } = 100; + + /// + /// Gets the on-demand sandbox activity identities to declare. Remote workers report their + /// registered activities separately when they connect. + /// + internal IList Activities { get; } = new List(); + + /// + /// Adds an activity name and version to the on-demand sandbox worker profile workerProfile. + /// + /// The activity name. + /// The activity version, or for unversioned/wildcard activity execution. + public void AddActivity(string activityName, string? version) + { + if (string.IsNullOrWhiteSpace(activityName)) + { + throw new ArgumentException("On-demand sandbox activity name cannot be empty.", nameof(activityName)); + } + + this.Activities.Add(new SandboxActivityMetadata.Activity( + activityName.Trim(), + SandboxActivityMetadata.NormalizeOptional(version))); + } + + /// + /// Adds an activity to the on-demand sandbox worker profile workerProfile using its durable task name. + /// + /// The activity type. + public void AddActivity() where TActivity : class, ITaskActivity + { + Type activityType = typeof(TActivity); + DurableTaskAttribute? attribute = activityType.GetCustomAttribute(); + string? activityName = attribute?.Name.Name; + this.AddActivity( + string.IsNullOrWhiteSpace(activityName) ? activityType.Name : activityName, + attribute?.Version); + } +} diff --git a/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileProvider.cs b/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileProvider.cs new file mode 100644 index 00000000..10cbb0f5 --- /dev/null +++ b/src/Client/AzureManaged.Sandboxes/SandboxWorkerProfileProvider.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using System.Threading; +using Microsoft.DurableTask.AzureManaged.Internal; + +namespace Microsoft.DurableTask.Client.AzureManaged; + +/// +/// Provides on-demand sandbox activity workerProfiles from worker profile configuration. +/// +sealed class SandboxWorkerProfileProvider +{ + readonly Lazy profiles; + + /// + /// Initializes a new instance of the class. + /// + public SandboxWorkerProfileProvider() + { + this.profiles = new Lazy(ScanProfiles, LazyThreadSafetyMode.ExecutionAndPublication); + } + + /// + /// Resolves on-demand sandbox workerProfiles for the specified task hub. + /// + /// The task hub name. + /// The resolved on-demand sandbox workerProfile options. + public IReadOnlyList ResolveWorkerProfiles(string taskHub) + { + string normalizedTaskHub = string.IsNullOrWhiteSpace(taskHub) + ? throw new InvalidOperationException("On-demand sandbox activity workerProfile requires a task hub name.") + : taskHub.Trim(); + + SandboxWorkerProfileOptions[] workerProfiles = this.profiles.Value + .Select(profile => CreateOptions(normalizedTaskHub, profile)) + .Where(static options => SandboxWorkerProfileBuilder.ResolveActivities(options.Activities).Length > 0) + .ToArray(); + + ValidateActivityOwnership(workerProfiles); + return workerProfiles; + } + + /// + /// Validates that a profile type can configure on-demand sandbox workerProfiles. + /// + /// The profile type. + internal static void ValidateProfileType(Type profileType) + { + if (!typeof(ISandboxWorkerProfile).IsAssignableFrom(profileType)) + { + throw new InvalidOperationException( + $"On-demand sandbox worker profile '{profileType.FullName}' must implement {nameof(ISandboxWorkerProfile)}."); + } + } + + static ProfileMetadata[] ScanProfiles() + { + Dictionary profiles = new(StringComparer.Ordinal); + foreach (Type type in GetCandidateTypes()) + { + if (type.GetCustomAttribute() is not { } profile) + { + continue; + } + + ValidateProfileType(type); + + if (profiles.ContainsKey(profile.WorkerProfileId)) + { + throw new InvalidOperationException($"On-demand sandbox worker profile '{profile.WorkerProfileId}' is declared more than once."); + } + + profiles.Add(profile.WorkerProfileId, new ProfileMetadata(profile.WorkerProfileId, type)); + } + + return profiles.Values.ToArray(); + } + + static SandboxWorkerProfileOptions CreateOptions( + string taskHub, + ProfileMetadata profile) + { + SandboxWorkerProfileOptions options = new() + { + TaskHub = taskHub, + WorkerProfileId = profile.WorkerProfileId, + }; + + ConfigureProfile(profile.Type, options); + return options; + } + + static void ConfigureProfile(Type profileType, SandboxWorkerProfileOptions options) + { + ValidateProfileType(profileType); + + object? instance = Activator.CreateInstance(profileType, nonPublic: true) + ?? throw new InvalidOperationException($"On-demand sandbox worker profile '{profileType.FullName}' could not be created."); + ((ISandboxWorkerProfile)instance).Configure(options); + } + + static IEnumerable GetCandidateTypes() + { + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + if (assembly.IsDynamic) + { + continue; + } + + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + types = ex.Types.Where(static type => type is not null).Cast().ToArray(); + } + + foreach (Type type in types) + { + yield return type; + } + } + } + + static void ValidateActivityOwnership(IEnumerable workerProfiles) + { + List<(SandboxActivityMetadata.Activity Activity, string WorkerProfileId)> activityOwners = []; + foreach (SandboxWorkerProfileOptions workerProfile in workerProfiles) + { + foreach (SandboxActivityMetadata.Activity activity in SandboxWorkerProfileBuilder.ResolveActivities(workerProfile.Activities)) + { + string? existingProfile = activityOwners + .Where(owner => SandboxActivityMetadata.ActivitiesOverlap(owner.Activity, activity)) + .Select(static owner => owner.WorkerProfileId) + .FirstOrDefault(profileId => !string.Equals(profileId, workerProfile.WorkerProfileId, StringComparison.Ordinal)); + if (existingProfile is not null) + { + throw new InvalidOperationException($"On-demand sandbox activity '{SandboxActivityMetadata.FormatActivity(activity)}' is assigned to both worker profile '{existingProfile}' and '{workerProfile.WorkerProfileId}'."); + } + + activityOwners.Add((activity, workerProfile.WorkerProfileId)); + } + } + } + + sealed record ProfileMetadata( + string WorkerProfileId, + Type Type); +} diff --git a/src/Grpc/orchestrator_service.proto b/src/Grpc/orchestrator_service.proto index 3d7c8eb4..3d9194ac 100644 --- a/src/Grpc/orchestrator_service.proto +++ b/src/Grpc/orchestrator_service.proto @@ -370,12 +370,14 @@ message OrchestratorResponse { // Whether or not a history is required to complete the original OrchestratorRequest and none was provided. bool requiresHistory = 7; + /* Chunking logic has since been deprecated and fields related to it are marked as such */ + // True if this is a partial (chunked) completion. The backend must keep the work item open until the final chunk (isPartial=false). - bool isPartial = 8; + bool isPartial = 8 [deprecated=true]; // Zero-based position of the current chunk within a chunked completion sequence. // This field is omitted for non-chunked completions. - google.protobuf.Int32Value chunkIndex = 9; + google.protobuf.Int32Value chunkIndex = 9 [deprecated=true]; } message CreateInstanceRequest { diff --git a/src/Grpc/refresh-protos.ps1 b/src/Grpc/refresh-protos.ps1 index a91393a4..8458d10c 100644 --- a/src/Grpc/refresh-protos.ps1 +++ b/src/Grpc/refresh-protos.ps1 @@ -17,14 +17,23 @@ $commitDetails = Invoke-RestMethod -Uri "https://api.github.com/repos/microsoft/ $commitId = $commitDetails.sha # These are the proto files we need to download from the durabletask-protobuf repository. -$protoFileNames = @( - "orchestrator_service.proto" +$protoFiles = @( + @{ + SourcePath = "orchestrator_service.proto" + OutputFileName = "orchestrator_service.proto" + }, + @{ + SourcePath = "durable-task-scheduler/sandbox_service.proto" + OutputFileName = "sandbox_service.proto" + } ) # Download each proto file to the local directory using the above commit ID -foreach ($protoFileName in $protoFileNames) { - $url = "https://raw.githubusercontent.com/microsoft/durabletask-protobuf/$commitId/protos/$protoFileName" - $outputFile = "$PSScriptRoot\$protoFileName" +foreach ($protoFile in $protoFiles) { + $sourcePath = $protoFile.SourcePath + $outputFileName = $protoFile.OutputFileName + $url = "https://raw.githubusercontent.com/microsoft/durabletask-protobuf/$commitId/protos/$sourcePath" + $outputFile = "$PSScriptRoot\$outputFileName" try { Invoke-WebRequest -Uri $url -OutFile $outputFile @@ -46,10 +55,11 @@ Add-Content ` -Path $versionsFile ` -Value "# The following files were downloaded from branch $branch at $(Get-Date -Format "yyyy-MM-dd HH:mm:ss" -AsUTC) UTC" -foreach ($protoFileName in $protoFileNames) { +foreach ($protoFile in $protoFiles) { + $sourcePath = $protoFile.SourcePath Add-Content ` -Path $versionsFile ` - -Value "https://raw.githubusercontent.com/microsoft/durabletask-protobuf/$commitId/protos/$protoFileName" + -Value "https://raw.githubusercontent.com/microsoft/durabletask-protobuf/$commitId/protos/$sourcePath" } Write-Host "Wrote commit ID $commitId to $versionsFile" -ForegroundColor Green diff --git a/src/Grpc/sandbox_service.proto b/src/Grpc/sandbox_service.proto new file mode 100644 index 00000000..1d52ed7d --- /dev/null +++ b/src/Grpc/sandbox_service.proto @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +syntax = "proto3"; + +package microsoft.durabletask.sandboxes; + +option csharp_namespace = "Microsoft.DurableTask.Protobuf.Sandboxes"; + +service SandboxActivities { + // Opens a live sandbox activity worker session. The first message + // must be a start message with static worker metadata. Heartbeats carry + // dynamic state only. Closing the stream deregisters the worker. + rpc ConnectSandboxActivityWorker(stream SandboxActivityWorkerMessage) returns (SandboxActivityWorkerSessionResult); + + // Creates or updates a sandbox worker profile before any live worker stream exists. + // This is a configuration contract and does not advertise active worker + // capacity. + rpc DeclareSandboxWorkerProfile(SandboxWorkerProfile) returns (DeclareSandboxWorkerProfileResult); + + // Removes a sandbox worker profile so the backend stops + // waking new sandbox workers for the specified worker profile. Existing + // workers are not terminated by this RPC. + rpc RemoveSandboxWorkerProfile(RemoveSandboxWorkerProfileRequest) returns (RemoveSandboxWorkerProfileResult); +} + +message SandboxActivityWorkerMessage { + oneof message { + SandboxActivityWorkerStart start = 1; + SandboxActivityWorkerHeartbeat heartbeat = 2; + } +} + +message SandboxActivityWorkerStart { + string task_hub = 1; + int32 max_activities_count = 2; + // Sandbox provider the worker is running in. UNSPECIFIED = legacy + // (pre-provider-aware) workers. + SandboxProviderKind sandbox_provider = 3; + // DTS-generated sandbox identifier injected as DTS_SANDBOX_ID. This is not + // the ADC provider sandbox resource id. + string dts_sandbox_identifier = 4; + // Caller-defined profile/catalog ID shared by worker profiles and live worker registration. + string worker_profile_id = 5; + // Activity handlers registered by the worker process. DTS validates this + // matches the worker profile before advertising worker capacity. + repeated SandboxActivity activities = 6; +} + +message SandboxActivityWorkerHeartbeat { + int32 active_activities_count = 1; +} + +message SandboxActivityWorkerSessionResult { + string message = 1; +} + +message SandboxWorkerProfile { + // Caller-defined profile/catalog ID shared by worker profiles and live worker registration. + string worker_profile_id = 1; + repeated SandboxActivity activities = 2; + SandboxActivityImage image = 3; + map environment_variables = 4; + int32 max_concurrent_activities = 5; + SandboxActivityResources resources = 6; + string scheduler_managed_identity_client_id = 7; +} + +message SandboxActivity { + string name = 1; + string version = 2; +} + +message SandboxActivityImage { + string image_ref = 1; + string managed_identity_client_id = 2; + repeated string entrypoint = 3; + repeated string cmd = 4; +} + +message SandboxActivityResources { + string cpu = 1; + string memory = 2; +} + +message DeclareSandboxWorkerProfileResult { +} + +message RemoveSandboxWorkerProfileRequest { + string worker_profile_id = 1; +} + +message RemoveSandboxWorkerProfileResult { +} + +// Sandbox provider executing the activity worker. +enum SandboxProviderKind { + SANDBOX_PROVIDER_KIND_UNSPECIFIED = 0; + SANDBOX_PROVIDER_KIND_ACA_SESSION_POOL = 1; + SANDBOX_PROVIDER_KIND_SANDBOX = 2; +} diff --git a/src/Grpc/versions.txt b/src/Grpc/versions.txt index b781a390..8ce5f427 100644 --- a/src/Grpc/versions.txt +++ b/src/Grpc/versions.txt @@ -1,2 +1,3 @@ -# The following files were downloaded from branch main at 2026-04-06 16:10:08 UTC -https://raw.githubusercontent.com/microsoft/durabletask-protobuf/bcf5af6a22caa70601bfc909918ba5937484279f/protos/orchestrator_service.proto +# The following files were downloaded from branch main at 2026-06-15 21:35:22 UTC +https://raw.githubusercontent.com/microsoft/durabletask-protobuf/67a2f1d3b9ea8a210eb86addb636a5d8a6484dbc/protos/orchestrator_service.proto +https://raw.githubusercontent.com/microsoft/durabletask-protobuf/67a2f1d3b9ea8a210eb86addb636a5d8a6484dbc/protos/durable-task-scheduler/sandbox_service.proto diff --git a/src/Shared/AzureManaged.Sandboxes/SandboxActivitiesGrpcTransport.cs b/src/Shared/AzureManaged.Sandboxes/SandboxActivitiesGrpcTransport.cs new file mode 100644 index 00000000..33246284 --- /dev/null +++ b/src/Shared/AzureManaged.Sandboxes/SandboxActivitiesGrpcTransport.cs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Grpc.Core; +using Proto = Microsoft.DurableTask.Protobuf.Sandboxes; + +namespace Microsoft.DurableTask.AzureManaged.Internal; + +/// +/// Transport abstraction for the on-demand sandbox activities gRPC service. +/// +interface ISandboxActivitiesTransport +{ + /// + /// Declares on-demand sandbox activities to DTS. + /// + /// The workerProfile message. + /// The task hub that owns the workerProfile. + /// The cancellation token. + /// The workerProfile result. + Task DeclareSandboxWorkerProfileAsync( + Proto.SandboxWorkerProfile workerProfile, + string taskHub, + CancellationToken cancellationToken); + + /// + /// Removes an on-demand sandbox activity workerProfile from DTS. + /// + /// The worker profile ID whose workerProfile should be removed. + /// The task hub that owns the workerProfile. + /// The cancellation token. + /// The removal result. + Task RemoveSandboxWorkerProfileAsync( + string workerProfileId, + string taskHub, + CancellationToken cancellationToken); + + /// + /// Opens an on-demand sandbox activity worker registration session. + /// + /// The task hub that owns the worker session. + /// The cancellation token. + /// The worker registration session. + ISandboxActivityWorkerSession OpenSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken); +} + +/// +/// Client-streaming session used by an on-demand sandbox activity worker registration. +/// +interface ISandboxActivityWorkerSession : 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.SandboxActivityWorkerMessage message); + + /// + /// Waits for the server to complete the worker registration session. + /// + /// 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(); +} + +/// +/// gRPC-backed implementation of . +/// +sealed class SandboxActivitiesGrpcTransport : ISandboxActivitiesTransport +{ + readonly Proto.SandboxActivities.SandboxActivitiesClient client; + readonly bool attachTaskHubMetadata; + + /// + /// Initializes a new instance of the class. + /// + /// The generated on-demand sandbox activities gRPC client. + /// True to add per-call task hub metadata when the underlying channel does not already do so. + public SandboxActivitiesGrpcTransport( + Proto.SandboxActivities.SandboxActivitiesClient client, + bool attachTaskHubMetadata = true) + { + this.client = Check.NotNull(client); + this.attachTaskHubMetadata = attachTaskHubMetadata; + } + + /// + public async Task DeclareSandboxWorkerProfileAsync( + Proto.SandboxWorkerProfile workerProfile, + string taskHub, + CancellationToken cancellationToken) + { + using AsyncUnaryCall call = + this.client.DeclareSandboxWorkerProfileAsync( + workerProfile, + headers: this.CreateTaskHubHeaders(taskHub), + cancellationToken: cancellationToken); + return await call.ResponseAsync.ConfigureAwait(false); + } + + /// + public async Task RemoveSandboxWorkerProfileAsync( + string workerProfileId, + string taskHub, + CancellationToken cancellationToken) + { + Proto.RemoveSandboxWorkerProfileRequest request = new() + { + WorkerProfileId = workerProfileId, + }; + + using AsyncUnaryCall call = + this.client.RemoveSandboxWorkerProfileAsync( + request, + headers: this.CreateTaskHubHeaders(taskHub), + cancellationToken: cancellationToken); + return await call.ResponseAsync.ConfigureAwait(false); + } + + /// + public ISandboxActivityWorkerSession OpenSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken) + { + AsyncClientStreamingCall call = + this.client.ConnectSandboxActivityWorker( + headers: this.CreateTaskHubHeaders(taskHub), + cancellationToken: cancellationToken); + return new GrpcSandboxActivityWorkerSession(call); + } + + Metadata? CreateTaskHubHeaders(string taskHub) => this.attachTaskHubMetadata + ? new Metadata { { "taskhub", taskHub }, } + : null; + + /// + /// gRPC-backed on-demand sandbox activity worker registration session. + /// + sealed class GrpcSandboxActivityWorkerSession : ISandboxActivityWorkerSession + { + readonly AsyncClientStreamingCall call; + + /// + /// Initializes a new instance of the class. + /// + /// The active gRPC client-streaming call. + public GrpcSandboxActivityWorkerSession(AsyncClientStreamingCall call) + { + this.call = call; + } + + /// + public Task WriteMessageAsync(Proto.SandboxActivityWorkerMessage message) => + this.call.RequestStream.WriteAsync(message); + + /// + 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() + { + this.call.Dispose(); + return default; + } + } +} diff --git a/src/Shared/AzureManaged.Sandboxes/SandboxActivityMetadata.cs b/src/Shared/AzureManaged.Sandboxes/SandboxActivityMetadata.cs new file mode 100644 index 00000000..4fabf25e --- /dev/null +++ b/src/Shared/AzureManaged.Sandboxes/SandboxActivityMetadata.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.AzureManaged.Internal; + +/// +/// Shared normalization helpers for on-demand sandbox activity metadata. +/// +static class SandboxActivityMetadata +{ + /// + /// Resolves configured activities for on-demand sandbox activity execution. + /// + /// The configured activities. + /// The normalized activities. + public static Activity[] ResolveActivities(IEnumerable configuredActivities) + { + List activities = []; + foreach (Activity activity in configuredActivities) + { + if (string.IsNullOrWhiteSpace(activity.Name)) + { + continue; + } + + Activity normalized = new( + activity.Name.Trim(), + NormalizeOptional(activity.Version)); + if (!activities.Any(existing => ActivityEquals(existing, normalized))) + { + activities.Add(normalized); + } + } + + return activities.ToArray(); + } + + /// + /// Normalizes an optional string. + /// + /// The value to normalize. + /// The trimmed value, or if it is empty. + public static string? NormalizeOptional(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + /// + /// Determines whether two activities represent the same activity identity. + /// + /// The left activity. + /// The right activity. + /// if the activities are equal; otherwise, . + public static bool ActivityEquals(Activity left, Activity right) => + string.Equals(left.Name, right.Name, StringComparison.OrdinalIgnoreCase) + && string.Equals(left.Version, right.Version, StringComparison.Ordinal); + + /// + /// Determines whether two activity filters can match overlapping work. + /// + /// The left activity. + /// The right activity. + /// if the activities overlap; otherwise, . + public static bool ActivitiesOverlap(Activity left, Activity right) => + string.Equals(left.Name, right.Name, StringComparison.OrdinalIgnoreCase) + && (left.Version is null + || right.Version is null + || string.Equals(left.Version, right.Version, StringComparison.Ordinal)); + + /// + /// Formats an activity identity for messages. + /// + /// The activity identity. + /// The formatted activity identity. + public static string FormatActivity(Activity activity) => + activity.Version is null ? activity.Name : $"{activity.Name}@{activity.Version}"; + + /// + /// Normalizes a worker profile ID. + /// + /// The worker profile ID. + /// The exception message to use when the value is empty. + /// The normalized worker profile ID. + public static string NormalizeWorkerProfileId(string value, string errorMessage) + { + return NormalizeRequired(value, errorMessage); + } + + /// + /// Normalizes a required string. + /// + /// The value to normalize. + /// The exception message to use when the value is empty. + /// The normalized value. + public static string NormalizeRequired(string value, string errorMessage) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException(errorMessage); + } + + return value.Trim(); + } + + /// + /// Represents a sandbox activity identity. + /// + /// The activity name. + /// The activity version, or for wildcard/unversioned execution. + public readonly record struct Activity(string Name, string? Version); +} diff --git a/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs new file mode 100644 index 00000000..45a251f6 --- /dev/null +++ b/src/Worker/AzureManaged.Sandboxes/DurableTaskSchedulerSandboxWorkerExtensions.cs @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; +using Azure.Core; +using Azure.Identity; +using Grpc.Net.Client; +using Microsoft.DurableTask.AzureManaged.Internal; +using Microsoft.DurableTask.Protobuf.Sandboxes; +using Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; +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 on-demand sandbox activity support. +/// +public static class DurableTaskSchedulerSandboxWorkerExtensions +{ + const string UseSandboxWorkerNoActivitiesErrorMessage = + "On-demand sandbox workers require at least one registered activity. " + + "Register an activity on this worker before starting the sandbox worker."; + + const string ManagedIdentityAuthentication = "ManagedIdentity"; + + /// + /// Configures this worker as an on-demand sandbox activity worker that connects to DTS to receive and execute + /// on-demand sandbox activities. Use this on a dedicated worker binary that runs inside sandbox infrastructure. + /// Runtime configuration is read from environment variables injected by DTS. + /// + /// The Durable Task worker builder to configure. + /// The original builder, for call chaining. + public static IDurableTaskWorkerBuilder UseSandboxWorker(this IDurableTaskWorkerBuilder builder) + { + Check.NotNull(builder); + + ConfigureDurableTaskSchedulerFromEnvironment(builder); + builder.UseWorkItemFilters(); + + builder.Services.AddOptions(builder.Name) + .PostConfigure>((options, schedulerOptions) => + { + ApplyRuntimeTaskHubDefault(options, schedulerOptions.Get(builder.Name).TaskHubName); + ApplyWorkerEnvironmentOverrides(options); + }); + + builder.Services.AddOptions(builder.Name) + .PostConfigure>((options, runtimeOptions) => + ConfigureSandboxWorkerConcurrency(options, runtimeOptions.Get(builder.Name))); + + builder.Services.AddOptions(builder.Name) + .PostConfigure(IncludeOnlyRegisteredActivities); + + 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 => CreateSandboxActivityWorkerRegistrationHostedService(sp, builder.Name)); + return builder; + } + + static void IncludeOnlyRegisteredActivities(DurableTaskWorkerWorkItemFilters filters) + { + if (filters.Activities.Count == 0) + { + throw new InvalidOperationException(UseSandboxWorkerNoActivitiesErrorMessage); + } + + filters.Orchestrations = []; + filters.Entities = []; + } + + static void ConfigureSandboxWorkerConcurrency( + DurableTaskWorkerOptions options, + SandboxWorkerRuntimeOptions runtimeOptions) + { + options.Concurrency.MaximumConcurrentActivityWorkItems = runtimeOptions.MaxConcurrentActivities; + options.Concurrency.MaximumConcurrentOrchestrationWorkItems = 0; + options.Concurrency.MaximumConcurrentEntityWorkItems = 0; + } + + static SandboxActivityWorkerRegistrationHostedService CreateSandboxActivityWorkerRegistrationHostedService( + IServiceProvider services, + string builderName) + { + SandboxWorkerRuntimeOptions options = services.GetRequiredService>().Get(builderName); + ILoggerFactory loggerFactory = services.GetRequiredService(); + IHostApplicationLifetime? lifetime = services.GetService(); + SandboxActivityTracker activityTracker = services.GetRequiredService(); + DurableTaskWorkerWorkItemFilters filters = services.GetRequiredService>().Get(builderName); + + return new SandboxActivityWorkerRegistrationHostedService( + CreateSandboxActivitiesTransport(services, builderName), + options, + ResolveActivityFilters(filters.Activities), + loggerFactory.CreateLogger(), + lifetime, + activityTracker); + } + + static SandboxActivitiesGrpcTransport CreateSandboxActivitiesTransport(IServiceProvider services, string builderName) + { + GrpcDurableTaskWorkerOptions options = services.GetRequiredService>().Get(builderName); + if (options.CallInvoker is { } callInvoker) + { + return new SandboxActivitiesGrpcTransport(new SandboxActivities.SandboxActivitiesClient(callInvoker)); + } + + if (options.Channel is { } channel) + { + return new SandboxActivitiesGrpcTransport( + new SandboxActivities.SandboxActivitiesClient(channel.CreateCallInvoker()), + attachTaskHubMetadata: false); + } + + throw new InvalidOperationException("Azure Managed on-demand sandbox activities require a configured gRPC channel or call invoker."); + } + + static void ApplyRuntimeTaskHubDefault(SandboxWorkerRuntimeOptions options, string taskHubName) + { + if (string.IsNullOrWhiteSpace(options.TaskHub) && !string.IsNullOrWhiteSpace(taskHubName)) + { + options.TaskHub = taskHubName; + } + } + + static void ConfigureDurableTaskSchedulerFromEnvironment(IDurableTaskWorkerBuilder builder) + { + string endpoint = GetRequiredEnvironmentVariable("DTS_ENDPOINT"); + string taskHub = GetRequiredEnvironmentVariable("DTS_TASK_HUB"); + string authentication = GetRequiredEnvironmentVariable("DTS_AUTHENTICATION"); + + builder.UseDurableTaskScheduler(options => + { + options.EndpointAddress = endpoint; + options.TaskHubName = taskHub; + if (UsesManagedIdentityAuthentication(authentication)) + { + options.Credential = CreateManagedIdentityCredential(); + options.AllowInsecureCredentials = false; + } + else + { + throw new InvalidOperationException( + $"DTS_AUTHENTICATION must be '{ManagedIdentityAuthentication}' for on-demand sandbox workers."); + } + }); + } + + static bool UsesManagedIdentityAuthentication(string authentication) => + string.Equals(authentication, ManagedIdentityAuthentication, StringComparison.OrdinalIgnoreCase); + + static ManagedIdentityCredential CreateManagedIdentityCredential() => + new ManagedIdentityCredential(ManagedIdentityId.FromUserAssignedClientId(GetRequiredEnvironmentVariable("DTS_UMI_CLIENT_ID"))); + + static string GetRequiredEnvironmentVariable(string name) + { + string? value = Environment.GetEnvironmentVariable(name); + return string.IsNullOrWhiteSpace(value) + ? throw new InvalidOperationException($"{name} must be injected by DTS for on-demand sandbox workers.") + : value.Trim(); + } + + static void ApplyWorkerEnvironmentOverrides(SandboxWorkerRuntimeOptions options) + { + ValidateSandboxWorkerSandboxProvider(GetRequiredEnvironmentVariable("DTS_SANDBOX_PROVIDER")); + + options.WorkerProfileId = GetRequiredEnvironmentVariable("DTS_WORKER_PROFILE_ID"); + + string? maxActivitiesValue = Environment.GetEnvironmentVariable("DTS_SANDBOX_MAX_ACTIVITIES"); + if (maxActivitiesValue is null) + { + return; + } + + if (!int.TryParse(maxActivitiesValue.Trim(), out int maxActivities) || maxActivities <= 0) + { + throw new InvalidOperationException( + "DTS_SANDBOX_MAX_ACTIVITIES must be a positive integer when injected by DTS for on-demand sandbox workers."); + } + + options.MaxConcurrentActivities = maxActivities; + } + + static void ValidateSandboxWorkerSandboxProvider(string sandboxProvider) + { + if (!string.Equals(sandboxProvider, "Sandbox", StringComparison.OrdinalIgnoreCase) + && !string.Equals(sandboxProvider, "AcaSessionPool", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "DTS_SANDBOX_PROVIDER must be 'Sandbox' or 'AcaSessionPool' for on-demand sandbox workers."); + } + } + + static SandboxActivityMetadata.Activity[] ResolveActivityFilters(IReadOnlyList activityFilters) + { + return SandboxActivityMetadata.ResolveActivities(activityFilters.SelectMany(static filter => + filter.Versions.Count == 0 + ? [new SandboxActivityMetadata.Activity(filter.Name, Version: null)] + : filter.Versions.Select(version => new SandboxActivityMetadata.Activity(filter.Name, version)))); + } +} diff --git a/src/Worker/AzureManaged.Sandboxes/Logs.cs b/src/Worker/AzureManaged.Sandboxes/Logs.cs new file mode 100644 index 00000000..0a0d8153 --- /dev/null +++ b/src/Worker/AzureManaged.Sandboxes/Logs.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using Proto = Microsoft.DurableTask.Protobuf.Sandboxes; + +namespace Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; + +/// +/// Log messages for on-demand sandbox activity services. +/// +static partial class Logs +{ + [LoggerMessage( + EventId = 701, + Level = LogLevel.Information, + Message = "On-demand sandbox activity worker registered hub={Hub} count={Count} sandboxProvider={SandboxProvider} sandboxId={SandboxId}")] + public static partial void SandboxActivityWorkerRegistered( + ILogger logger, string hub, int count, Proto.SandboxProviderKind sandboxProvider, string sandboxId); + + [LoggerMessage( + EventId = 702, + Level = LogLevel.Error, + Message = "On-demand sandbox activity worker registration stream failed hub={Hub}")] + public static partial void SandboxActivityWorkerRegistrationFailed(ILogger logger, Exception exception, string hub); + + [LoggerMessage( + EventId = 703, + Level = LogLevel.Debug, + Message = "Ignoring on-demand sandbox worker session completion failure during shutdown.")] + public static partial void SandboxWorkerSessionCompletionFailureIgnored(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 704, + Level = LogLevel.Debug, + Message = "Ignoring on-demand sandbox worker registration pump cancellation during shutdown.")] + public static partial void SandboxWorkerRegistrationPumpCancellationIgnored(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 705, + Level = LogLevel.Debug, + Message = "Ignoring on-demand sandbox worker registration pump failure during shutdown.")] + public static partial void SandboxWorkerRegistrationPumpFailureIgnored(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 706, + Level = LogLevel.Debug, + Message = "Ignoring on-demand sandbox worker session dispose failure during shutdown.")] + public static partial void SandboxWorkerSessionDisposeFailureIgnored(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 707, + Level = LogLevel.Debug, + Message = "Ignoring on-demand sandbox heartbeat pump cancellation after registration session completion.")] + public static partial void SandboxHeartbeatPumpCancellationIgnored(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 708, + Level = LogLevel.Debug, + Message = "Ignoring on-demand sandbox heartbeat pump failure after registration session completion.")] + public static partial void SandboxHeartbeatPumpFailureIgnored(ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 709, + Level = LogLevel.Debug, + Message = "Ignoring on-demand sandbox worker session completion failure after heartbeat pump failure.")] + public static partial void SandboxWorkerSessionCompletionAfterHeartbeatFailureIgnored(ILogger logger, Exception exception); +} diff --git a/src/Worker/AzureManaged.Sandboxes/SandboxActivityTracker.cs b/src/Worker/AzureManaged.Sandboxes/SandboxActivityTracker.cs new file mode 100644 index 00000000..610a45fa --- /dev/null +++ b/src/Worker/AzureManaged.Sandboxes/SandboxActivityTracker.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; + +/// +/// Tracks activity execution state for an on-demand sandbox worker process. +/// +sealed class SandboxActivityTracker +{ + 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/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs b/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs new file mode 100644 index 00000000..efc862f7 --- /dev/null +++ b/src/Worker/AzureManaged.Sandboxes/SandboxActivityWorkerRegistrationHostedService.cs @@ -0,0 +1,443 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IO; +using Grpc.Core; +using Microsoft.DurableTask.AzureManaged.Internal; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Proto = Microsoft.DurableTask.Protobuf.Sandboxes; + +namespace Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; + +/// +/// Hosted service that registers a running process as an on-demand sandbox activity worker with DTS. +/// +sealed class SandboxActivityWorkerRegistrationHostedService : IHostedService, IAsyncDisposable +{ + readonly object sync = new(); + readonly ISandboxActivitiesTransport transport; + readonly SandboxWorkerRuntimeOptions options; + readonly IReadOnlyCollection registeredActivities; + readonly ILogger logger; + readonly IHostApplicationLifetime? lifetime; + readonly SandboxActivityTracker? activityTracker; + readonly Random reconnectJitter; + readonly SemaphoreSlim streamSync = new(1, 1); + CancellationTokenSource? cts; + ISandboxActivityWorkerSession? session; + Task? pump; + + /// + /// Initializes a new instance of the class. + /// + /// The on-demand sandbox activities transport. + /// The on-demand sandbox worker runtime options. + /// The activity handlers registered by this worker process. + /// The logger. + /// The optional application lifetime used to stop the host when a non-retriable registration stream failure occurs. + /// The optional activity tracker used to report live in-flight activity count. + /// The optional random source used to jitter reconnect delays. + public SandboxActivityWorkerRegistrationHostedService( + ISandboxActivitiesTransport transport, + SandboxWorkerRuntimeOptions options, + IReadOnlyCollection registeredActivities, + ILogger logger, + IHostApplicationLifetime? lifetime = null, + SandboxActivityTracker? activityTracker = null, + Random? reconnectJitter = null) + { + this.transport = Check.NotNull(transport); + this.options = Check.NotNull(options); + this.registeredActivities = Check.NotNull(registeredActivities); + this.logger = Check.NotNull(logger); + this.lifetime = lifetime; + this.activityTracker = activityTracker; + this.reconnectJitter = reconnectJitter ?? Random.Shared; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + SandboxActivityMetadata.Activity[] activities = SandboxActivityMetadata.ResolveActivities(this.registeredActivities); + CancellationTokenSource registrationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task registrationPump = Task.Run( + () => this.RunRegistrationLoopAsync(activities.Length, registrationCts.Token), + CancellationToken.None); + lock (this.sync) + { + this.cts = registrationCts; + this.pump = registrationPump; + } + + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + CancellationTokenSource? localCts; + ISandboxActivityWorkerSession? localSession; + Task? localPump; + lock (this.sync) + { + localCts = this.cts; + localSession = this.session; + localPump = this.pump; + } + + localCts?.Cancel(); + + if (localSession is not null) + { + try + { + await this.CompleteSessionAsync(localSession, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or RpcException) + { + Logs.SandboxWorkerSessionCompletionFailureIgnored(this.logger, ex); + } + } + + if (localPump is not null) + { + try + { + await localPump.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) + { + Logs.SandboxWorkerRegistrationPumpCancellationIgnored(this.logger, ex); + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or RpcException) + { + Logs.SandboxWorkerRegistrationPumpFailureIgnored(this.logger, ex); + } + } + + lock (this.sync) + { + if (ReferenceEquals(this.cts, localCts)) + { + this.cts = null; + } + + if (ReferenceEquals(this.session, localSession)) + { + this.session = null; + } + + if (ReferenceEquals(this.pump, localPump)) + { + this.pump = Task.CompletedTask; + } + } + + localCts?.Dispose(); + } + + /// + public async ValueTask DisposeAsync() + { + await this.StopAsync(CancellationToken.None).ConfigureAwait(false); + this.streamSync.Dispose(); + } + + /// + /// 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); + } + + /// + /// Computes the next exponential retry delay, capped at the configured maximum delay. + /// + /// The current retry delay. + /// The maximum retry delay. + /// The next retry delay. + internal static TimeSpan ComputeNextRetryDelay(TimeSpan retryDelay, TimeSpan maxDelay) + { + if (retryDelay <= TimeSpan.Zero) + { + return retryDelay; + } + + if (retryDelay >= maxDelay || retryDelay.Ticks > maxDelay.Ticks / 2) + { + return maxDelay; + } + + return TimeSpan.FromTicks(retryDelay.Ticks * 2); + } + + static async ValueTask DisposeSessionAsync( + ISandboxActivityWorkerSession registrationSession, + ILogger logger) + { + try + { + await registrationSession.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or RpcException) + { + Logs.SandboxWorkerSessionDisposeFailureIgnored(logger, ex); + } + } + + 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); + + static bool IsFatalException(Exception ex) => ex is OutOfMemoryException + or StackOverflowException + or AccessViolationException + or ThreadAbortException; + + static async Task ObserveCompletionFailureAfterHeartbeatFailureAsync( + Task completionTask, + ILogger logger) + { + try + { + await completionTask.ConfigureAwait(false); + } + catch (Exception ex) when (!IsFatalException(ex)) + { + Logs.SandboxWorkerSessionCompletionAfterHeartbeatFailureIgnored(logger, ex); + } + } + + async Task RunRegistrationLoopAsync(int activityCount, CancellationToken cancellationToken) + { + TimeSpan retryDelay = this.GetInitialRetryDelay(); + while (!cancellationToken.IsCancellationRequested) + { + ISandboxActivityWorkerSession? registrationSession = null; + try + { + Proto.SandboxActivityWorkerMessage startMessage = SandboxWorkerMessageBuilder.BuildWorkerStart(this.options, this.registeredActivities); + registrationSession = this.transport.OpenSandboxActivityWorkerSession(startMessage.Start.TaskHub, cancellationToken); + this.SetCurrentSession(registrationSession); + + await this.WriteSessionMessageAsync(registrationSession, startMessage, cancellationToken).ConfigureAwait(false); + Logs.SandboxActivityWorkerRegistered( + this.logger, + startMessage.Start.TaskHub, + activityCount, + startMessage.Start.SandboxProvider, + startMessage.Start.DtsSandboxIdentifier); + + await this.RunRegistrationSessionAsync(registrationSession, cancellationToken).ConfigureAwait(false); + retryDelay = this.GetInitialRetryDelay(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (RpcException ex) when (IsRetriableRegistrationFailure(ex)) + { + retryDelay = await this.HandleRetriableRegistrationFailureAsync( + ex, + retryDelay, + cancellationToken).ConfigureAwait(false); + } + catch (IOException ex) when (IsRetriableRegistrationFailure(ex)) + { + retryDelay = await this.HandleRetriableRegistrationFailureAsync( + ex, + retryDelay, + cancellationToken).ConfigureAwait(false); + } + catch (ObjectDisposedException ex) when (IsRetriableRegistrationFailure(ex)) + { + retryDelay = await this.HandleRetriableRegistrationFailureAsync( + ex, + retryDelay, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (IsRetriableRegistrationFailure(ex)) + { + retryDelay = await this.HandleRetriableRegistrationFailureAsync( + ex, + retryDelay, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (!IsFatalException(ex)) + { + Logs.SandboxActivityWorkerRegistrationFailed(this.logger, ex, this.options.TaskHub); + this.lifetime?.StopApplication(); + break; + } + finally + { + if (registrationSession is not null) + { + this.ClearCurrentSession(registrationSession); + await DisposeSessionAsync(registrationSession, this.logger).ConfigureAwait(false); + } + } + } + } + + async Task HandleRetriableRegistrationFailureAsync( + Exception exception, + TimeSpan retryDelay, + CancellationToken cancellationToken) + { + Logs.SandboxActivityWorkerRegistrationFailed(this.logger, exception, this.options.TaskHub); + await this.DelayBeforeReconnectAsync(retryDelay, cancellationToken).ConfigureAwait(false); + return this.GetNextRetryDelay(retryDelay); + } + + async Task RunRegistrationSessionAsync( + ISandboxActivityWorkerSession 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)) + { + heartbeatCts.Cancel(); + try + { + await heartbeatTask.ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (heartbeatCts.IsCancellationRequested) + { + Logs.SandboxHeartbeatPumpCancellationIgnored(this.logger, ex); + } + catch (RpcException ex) + { + // The server response is authoritative once the response task wins the race. + Logs.SandboxHeartbeatPumpFailureIgnored(this.logger, ex); + } + catch (IOException ex) + { + // The server response is authoritative once the response task wins the race. + Logs.SandboxHeartbeatPumpFailureIgnored(this.logger, ex); + } + catch (ObjectDisposedException ex) + { + // The server response is authoritative once the response task wins the race. + Logs.SandboxHeartbeatPumpFailureIgnored(this.logger, ex); + } + + await completionTask.ConfigureAwait(false); + return; + } + + try + { + await heartbeatTask.ConfigureAwait(false); + } + finally + { + _ = ObserveCompletionFailureAfterHeartbeatFailureAsync(completionTask, this.logger); + } + } + + async Task PumpHeartbeatsAsync( + ISandboxActivityWorkerSession registrationSession, + CancellationToken cancellationToken) + { + using PeriodicTimer timer = new(this.options.HeartbeatInterval); + while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + { + int activeActivitiesCount = this.activityTracker?.InFlightCount ?? 0; + await this.WriteSessionMessageAsync( + registrationSession, + SandboxWorkerMessageBuilder.BuildWorkerHeartbeat(activeActivitiesCount), + cancellationToken).ConfigureAwait(false); + } + } + + async Task WriteSessionMessageAsync( + ISandboxActivityWorkerSession registrationSession, + Proto.SandboxActivityWorkerMessage 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( + ISandboxActivityWorkerSession registrationSession, + CancellationToken cancellationToken) + { + await this.streamSync.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await registrationSession.CompleteAsync().WaitAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + this.streamSync.Release(); + } + } + + void SetCurrentSession(ISandboxActivityWorkerSession registrationSession) + { + lock (this.sync) + { + this.session = registrationSession; + } + } + + void ClearCurrentSession(ISandboxActivityWorkerSession registrationSession) + { + lock (this.sync) + { + if (ReferenceEquals(this.session, registrationSession)) + { + this.session = null; + } + } + } + + TimeSpan GetInitialRetryDelay() => + this.options.WorkerRegistrationRetryInitialDelay <= this.options.WorkerRegistrationRetryMaxDelay + ? this.options.WorkerRegistrationRetryInitialDelay + : this.options.WorkerRegistrationRetryMaxDelay; + + TimeSpan GetNextRetryDelay(TimeSpan retryDelay) => + ComputeNextRetryDelay(retryDelay, this.options.WorkerRegistrationRetryMaxDelay); + + async Task DelayBeforeReconnectAsync(TimeSpan retryDelay, CancellationToken cancellationToken) + { + TimeSpan jitteredDelay = ComputeJitteredReconnectDelay(retryDelay, this.reconnectJitter); + if (jitteredDelay > TimeSpan.Zero) + { + await Task.Delay(jitteredDelay, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/Worker/AzureManaged.Sandboxes/SandboxWorkerMessageBuilder.cs b/src/Worker/AzureManaged.Sandboxes/SandboxWorkerMessageBuilder.cs new file mode 100644 index 00000000..6593adad --- /dev/null +++ b/src/Worker/AzureManaged.Sandboxes/SandboxWorkerMessageBuilder.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.AzureManaged.Internal; +using Proto = Microsoft.DurableTask.Protobuf.Sandboxes; + +namespace Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; + +/// +/// Builds on-demand sandbox activity worker registration protocol messages. +/// +static class SandboxWorkerMessageBuilder +{ + /// + /// Builds the initial on-demand sandbox activity worker registration message. + /// + /// The on-demand sandbox options. + /// The activity handlers registered by the worker process. + /// The worker start protocol message. + public static Proto.SandboxActivityWorkerMessage BuildWorkerStart( + SandboxWorkerRuntimeOptions options, + IReadOnlyCollection registeredActivities) + { + Check.NotNull(options); + Check.NotNull(registeredActivities); + + string taskHub = SandboxActivityMetadata.NormalizeRequired( + options.TaskHub, + "On-demand sandbox activity worker registration requires a task hub name."); + SandboxActivityMetadata.Activity[] activities = SandboxActivityMetadata.ResolveActivities(registeredActivities); + if (activities.Length == 0) + { + throw new InvalidOperationException("On-demand sandbox activity worker registration requires at least one registered activity."); + } + + if (options.MaxConcurrentActivities <= 0) + { + throw new InvalidOperationException("On-demand sandbox activity worker max activity count must be greater than zero."); + } + + string workerProfileId = SandboxActivityMetadata.NormalizeWorkerProfileId( + options.WorkerProfileId, + "On-demand sandbox activity worker registration requires a worker profile ID."); + string dtsSandboxIdentifier = SandboxActivityMetadata.NormalizeRequired( + Environment.GetEnvironmentVariable("DTS_SANDBOX_ID") ?? string.Empty, + "On-demand sandbox activity worker registration requires a DTS sandbox ID."); + + Proto.SandboxActivityWorkerStart start = new() + { + TaskHub = taskHub, + WorkerProfileId = workerProfileId, + MaxActivitiesCount = options.MaxConcurrentActivities, + SandboxProvider = GetSandboxProviderFromEnvironment(), + DtsSandboxIdentifier = dtsSandboxIdentifier, + }; + start.Activities.AddRange(activities.Select(ToProtoActivity)); + + return new Proto.SandboxActivityWorkerMessage { Start = start }; + } + + /// + /// Builds an on-demand sandbox activity worker heartbeat message. + /// + /// The number of activities currently executing. + /// The heartbeat protocol message. + public static Proto.SandboxActivityWorkerMessage BuildWorkerHeartbeat(int activeActivitiesCount) + { + if (activeActivitiesCount < 0) + { + throw new InvalidOperationException("On-demand sandbox activity worker active activity count cannot be negative."); + } + + return new Proto.SandboxActivityWorkerMessage + { + Heartbeat = new Proto.SandboxActivityWorkerHeartbeat + { + ActiveActivitiesCount = activeActivitiesCount, + }, + }; + } + + static Proto.SandboxActivity ToProtoActivity(SandboxActivityMetadata.Activity activity) => new() + { + Name = activity.Name, + Version = activity.Version ?? string.Empty, + }; + + static Proto.SandboxProviderKind GetSandboxProviderFromEnvironment() + { + string? sandboxProvider = Environment.GetEnvironmentVariable("DTS_SANDBOX_PROVIDER"); + if (sandboxProvider is null) + { + return Proto.SandboxProviderKind.Unspecified; + } + + if (sandboxProvider.Equals("Sandbox", StringComparison.OrdinalIgnoreCase)) + { + return Proto.SandboxProviderKind.Sandbox; + } + + if (sandboxProvider.Equals("AcaSessionPool", StringComparison.OrdinalIgnoreCase)) + { + return Proto.SandboxProviderKind.AcaSessionPool; + } + + return Proto.SandboxProviderKind.Unspecified; + } +} diff --git a/src/Worker/AzureManaged.Sandboxes/SandboxWorkerRuntimeOptions.cs b/src/Worker/AzureManaged.Sandboxes/SandboxWorkerRuntimeOptions.cs new file mode 100644 index 00000000..e3a82e46 --- /dev/null +++ b/src/Worker/AzureManaged.Sandboxes/SandboxWorkerRuntimeOptions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; + +/// +/// Internal runtime settings for an on-demand sandbox worker process. +/// +internal sealed class SandboxWorkerRuntimeOptions +{ + /// + /// Gets or sets the task hub used by on-demand sandbox worker registration. + /// + public string TaskHub { get; set; } = string.Empty; + + /// + /// Gets or sets the worker profile ID used by on-demand sandbox worker registration. + /// + public string WorkerProfileId { get; set; } = string.Empty; + + /// + /// Gets or sets the maximum number of concurrent activities expected from this on-demand sandbox 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); +} diff --git a/src/Worker/AzureManaged.Sandboxes/Worker.AzureManaged.Sandboxes.csproj b/src/Worker/AzureManaged.Sandboxes/Worker.AzureManaged.Sandboxes.csproj new file mode 100644 index 00000000..6fbe8aec --- /dev/null +++ b/src/Worker/AzureManaged.Sandboxes/Worker.AzureManaged.Sandboxes.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + Azure Managed on-demand sandbox activity worker extensions for the Durable Task Framework worker. + Microsoft.DurableTask.Worker.AzureManaged.Sandboxes + true + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs b/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs index 3b9d4e55..2b832a54 100644 --- a/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs +++ b/src/Worker/AzureManaged/DurableTaskSchedulerWorkerExtensions.cs @@ -300,6 +300,7 @@ and not AccessViolationException } } } + GC.SuppressFinalize(this); } diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs index 7b7c41c7..1ff3dc39 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.NotifyActivity(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.NotifyActivity(ActivityNotificationPhase.Completed); + } + }, cancellation); } else if (workItem.RequestCase == P.WorkItem.RequestOneofCase.EntityRequest) @@ -449,6 +460,27 @@ void DispatchWorkItem(P.WorkItem workItem, CancellationToken cancellation) } } + void NotifyActivity(ActivityNotificationPhase phase) + { + Action? callback = this.internalOptions.NotifyActivity; + if (callback is null) + { + return; + } + + try + { + callback(phase); + } + catch (Exception ex) when (ex is not OutOfMemoryException + and not StackOverflowException + and not AccessViolationException + and not ThreadAbortException) + { + this.Logger.ActivityNotificationFailed(phase, ex); + } + } + void RunBackgroundTask(P.WorkItem? workItem, Func handler, CancellationToken cancellation) { // TODO: is Task.Run appropriate here? Should we have finer control over the tasks and their threads? diff --git a/src/Worker/Grpc/GrpcDurableTaskWorkerOptions.cs b/src/Worker/Grpc/GrpcDurableTaskWorkerOptions.cs index 49bd6350..59c21a00 100644 --- a/src/Worker/Grpc/GrpcDurableTaskWorkerOptions.cs +++ b/src/Worker/Grpc/GrpcDurableTaskWorkerOptions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.DurableTask.Worker.Grpc.Internal; using P = Microsoft.DurableTask.Protobuf; namespace Microsoft.DurableTask.Worker.Grpc; @@ -165,5 +166,10 @@ internal class InternalOptions /// deferring disposal of the old channel so in-flight RPCs already using it are not interrupted. /// public Func>? ChannelRecreator { get; set; } + + /// + /// Gets or sets a callback that is invoked when activity work items are received or finished. + /// + public Action? NotifyActivity { get; set; } } } diff --git a/src/Worker/Grpc/Internal/InternalOptionsExtensions.cs b/src/Worker/Grpc/Internal/InternalOptionsExtensions.cs index b26b36cc..81ad09d5 100644 --- a/src/Worker/Grpc/Internal/InternalOptionsExtensions.cs +++ b/src/Worker/Grpc/Internal/InternalOptionsExtensions.cs @@ -2,11 +2,25 @@ // Licensed under the MIT License. using System; -using System.Collections.Generic; -using System.Text; 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 +42,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/src/Worker/Grpc/Logs.cs b/src/Worker/Grpc/Logs.cs index 878efe9c..f378a7fc 100644 --- a/src/Worker/Grpc/Logs.cs +++ b/src/Worker/Grpc/Logs.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.DurableTask.Worker.Grpc.Internal; using Microsoft.Extensions.Logging; namespace Microsoft.DurableTask.Worker.Grpc @@ -99,9 +100,12 @@ static partial class Logs public static partial void ReceivedHealthPing(this ILogger logger); [LoggerMessage(EventId = 76, Level = LogLevel.Information, Message = "Work-item stream ended by the backend (graceful close). Will reconnect.")] - public static partial void StreamEndedByPeer(this ILogger logger); - + public static partial void StreamEndedByPeer(this ILogger logger); + [LoggerMessage(EventId = 77, Level = LogLevel.Warning, Message = "Transient gRPC error for '{OperationName}'. Attempt {Attempt} of {MaxAttempts}. Retrying in {BackoffMs} ms. StatusCode={StatusCode}")] public static partial void TransientGrpcRetry(this ILogger logger, string operationName, int attempt, int maxAttempts, double backoffMs, int statusCode, Exception exception); + + [LoggerMessage(EventId = 78, Level = LogLevel.Warning, Message = "Activity notification callback failed for phase '{Phase}'.")] + public static partial void ActivityNotificationFailed(this ILogger logger, ActivityNotificationPhase phase, Exception exception); } } diff --git a/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj b/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj index ba569a77..b9b8cd70 100644 --- a/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj +++ b/test/Client/AzureManaged.Tests/Client.AzureManaged.Tests.csproj @@ -4,10 +4,6 @@ net10.0 - - - - @@ -15,6 +11,7 @@ + diff --git a/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs new file mode 100644 index 00000000..9c7dc1e3 --- /dev/null +++ b/test/Client/AzureManaged.Tests/SandboxActivitiesClientTests.cs @@ -0,0 +1,448 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using FluentAssertions; +using Grpc.Core; +using Microsoft.DurableTask.AzureManaged.Internal; +using Microsoft.DurableTask.Client.Grpc; +using Microsoft.DurableTask.Protobuf.Sandboxes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.DurableTask.Client.AzureManaged.Tests; + +public class SandboxActivitiesClientTests +{ + const string TaskHub = "testhub"; + + [Fact] + public void OnDemandSandboxWorkerProfileContract_DoesNotExposeRemovedOptions() + { + typeof(SandboxWorkerProfileOptions).GetProperty("LaunchCommand").Should().BeNull(); + typeof(SandboxWorkerProfileOptions).GetProperty("WorkerProfileRetryMaxAttempts").Should().BeNull(); + typeof(SandboxWorkerProfileOptions).GetProperty("WorkerProfileRetryDelay").Should().BeNull(); + typeof(SandboxWorkerProfileOptions).GetProperty( + "HeartbeatInterval", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); + typeof(SandboxWorkerProfileOptions).GetProperty("WakeupPort").Should().BeNull(); + typeof(SandboxWorkerProfileOptions).GetProperty( + "WorkerRegistrationRetryInitialDelay", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); + typeof(SandboxWorkerProfileOptions).GetProperty( + "WorkerRegistrationRetryMaxDelay", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); + typeof(SandboxWorkerProfileOptions).GetProperty( + "Mode", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Should().BeNull(); + typeof(SandboxWorkerProfile).GetProperty("LaunchCommand").Should().BeNull(); + } + + [Fact] + public void OnDemandSandboxWorkerProfileContract_ExposesProfileAddActivityOnly() + { + // Arrange + Type optionsType = typeof(SandboxWorkerProfileOptions); + Type? activityAttributeType = typeof(SandboxWorkerProfileOptions).Assembly.GetType( + "Microsoft.DurableTask.Client.AzureManaged.SandboxesActivityAttribute"); + + // Act/Assert + optionsType.GetProperty("ActivityNames").Should().BeNull(); + optionsType.GetMethod("AddActivity", [typeof(string)]).Should().BeNull(); + optionsType.GetMethod("AddActivity", [typeof(string), typeof(string)]).Should().NotBeNull(); + optionsType.GetMethods().Should().Contain(method => + method.Name == "AddActivity" && method.IsGenericMethodDefinition); + activityAttributeType.Should().BeNull(); + } + + [Theory] + [InlineData("500m", "1024Mi")] + [InlineData("0.5", "1Gi")] + [InlineData("2", "2048")] + public void SandboxWorkerProfileBuilder_BuildWorkerProfile_AcceptsAdcResourceQuantities( + string cpu, + string memory) + { + // Arrange + SandboxWorkerProfileOptions options = CreateWorkerProfileOptions(); + options.Cpu = cpu; + options.Memory = memory; + + // Act + SandboxWorkerProfile workerProfile = SandboxWorkerProfileBuilder.BuildWorkerProfile( + options, + SandboxWorkerProfileBuilder.ResolveActivities(options.Activities)); + + // Assert + workerProfile.Resources.Cpu.Should().Be(cpu); + workerProfile.Resources.Memory.Should().Be(memory); + } + + [Theory] + [InlineData("0", "1024Mi", "CPU")] + [InlineData("0m", "1024Mi", "CPU")] + [InlineData("500.5m", "1024Mi", "CPU")] + [InlineData("500Mi", "1024Mi", "CPU")] + [InlineData("500m", "0", "memory")] + [InlineData("500m", "0Mi", "memory")] + [InlineData("500m", "500m", "memory")] + public void SandboxWorkerProfileBuilder_BuildWorkerProfile_RejectsInvalidAdcResourceQuantities( + string cpu, + string memory, + string expectedMessage) + { + // Arrange + SandboxWorkerProfileOptions options = CreateWorkerProfileOptions(); + options.Cpu = cpu; + options.Memory = memory; + + // Act + Action action = () => SandboxWorkerProfileBuilder.BuildWorkerProfile( + options, + SandboxWorkerProfileBuilder.ResolveActivities(options.Activities)); + + // Assert + action.Should().Throw() + .WithMessage($"*{expectedMessage}*"); + } + + [Fact] + public void SandboxWorkerProfileBuilder_ResolveActivities_DeduplicatesCaseInsensitively() + { + // Arrange + SandboxWorkerProfileOptions options = CreateWorkerProfileOptions(); + options.AddActivity(" RemoteHello ", version: null); + options.AddActivity("remotehello", version: null); + options.AddActivity("Other", "v1"); + + // Act + SandboxActivityMetadata.Activity[] activities = SandboxWorkerProfileBuilder.ResolveActivities(options.Activities); + + // Assert + activities.Should().Equal( + new SandboxActivityMetadata.Activity("RemoteHello", Version: null), + new SandboxActivityMetadata.Activity("Other", "v1")); + } + + [Fact] + public void SandboxWorkerProfileBuilder_BuildWorkerProfile_RequiresSchedulerManagedIdentityClientId() + { + // Arrange + SandboxWorkerProfileOptions options = CreateWorkerProfileOptions(); + options.SchedulerManagedIdentityClientId = " "; + + // Act + Action action = () => SandboxWorkerProfileBuilder.BuildWorkerProfile( + options, + SandboxWorkerProfileBuilder.ResolveActivities(options.Activities)); + + // Assert + action.Should().Throw() + .WithMessage("*managed identity client ID*"); + } + + [Fact] + public void SandboxWorkerProfileBuilder_BuildWorkerProfile_RequiresImagePullManagedIdentityClientId() + { + // Arrange + SandboxWorkerProfileOptions options = CreateWorkerProfileOptions(); + options.ImagePullManagedIdentityClientId = " "; + + // Act + Action action = () => SandboxWorkerProfileBuilder.BuildWorkerProfile( + options, + SandboxWorkerProfileBuilder.ResolveActivities(options.Activities)); + + // Assert + action.Should().Throw() + .WithMessage("*managed identity client ID ADC uses to pull the worker image*"); + } + + [Fact] + public void SandboxWorkerProfileProvider_ResolveWorkerProfiles_UsesWorkerProfileConfigure() + { + // Arrange + using EnvironmentVariableScope image = new("DTS_ON_DEMAND_SANDBOX_ACTIVITY_IMAGE", "example.com/not-used:latest"); + using EnvironmentVariableScope cpu = new("DTS_ON_DEMAND_SANDBOX_CPU", "2000m"); + using EnvironmentVariableScope memory = new("DTS_ON_DEMAND_SANDBOX_MEMORY", "4096Mi"); + using EnvironmentVariableScope maxActivities = new("DTS_SANDBOX_MAX_ACTIVITIES", "99"); + + SandboxWorkerProfileProvider provider = new(); + + // Act + SandboxWorkerProfileOptions options = provider.ResolveWorkerProfiles(TaskHub) + .Single(options => options.WorkerProfileId == "annotated-profile"); + SandboxWorkerProfile workerProfile = SandboxWorkerProfileBuilder.BuildWorkerProfile( + options, + SandboxWorkerProfileBuilder.ResolveActivities(options.Activities)); + + // Assert + workerProfile.WorkerProfileId.Should().Be("annotated-profile"); + workerProfile.Activities.Select(static activity => activity.Name).Should().Equal("ConfiguredRemoteHello"); + workerProfile.Activities.Select(static activity => activity.Version).Should().Equal("v1"); + workerProfile.Image.ImageRef.Should().Be("example.com/repo/annotated-worker:latest"); + workerProfile.Image.ManagedIdentityClientId.Should().Be("image-pull-client-id"); + workerProfile.SchedulerManagedIdentityClientId.Should().Be("scheduler-client-id"); + workerProfile.Resources.Cpu.Should().Be("500m"); + workerProfile.Resources.Memory.Should().Be("1024Mi"); + workerProfile.MaxConcurrentActivities.Should().Be(4); + workerProfile.EnvironmentVariables.Should().ContainKey("CUSTOM_ENV").WhoseValue.Should().Be("configured-value"); + workerProfile.Image.Entrypoint.Should().BeEmpty(); + workerProfile.Image.Cmd.Should().BeEmpty(); + } + + [Fact] + public void SandboxWorkerProfileProvider_ValidateProfileType_RequiresProfileInterface() + { + // Arrange + // Act + Action action = () => SandboxWorkerProfileProvider.ValidateProfileType(typeof(ProfileWithoutInterface)); + + // Assert + action.Should().Throw() + .WithMessage($"*{nameof(ISandboxWorkerProfile)}*"); + } + + [Fact] + public void SandboxWorkerProfileProvider_ResolveWorkerProfiles_DetectsCaseInsensitiveActivityOwnership() + { + // Arrange + using EnvironmentVariableScope enableDuplicateCaseProfiles = new( + "DTS_TEST_ENABLE_CASE_DUPLICATE_SANDBOX_PROFILES", + "true"); + SandboxWorkerProfileProvider provider = new(); + + // Act + Action action = () => provider.ResolveWorkerProfiles(TaskHub); + + // Assert + action.Should().Throw() + .Where(ex => ex.Message.Contains("CaseActivity", StringComparison.OrdinalIgnoreCase) + && ex.Message.Contains("case-profile-a", StringComparison.Ordinal) + && ex.Message.Contains("case-profile-b", StringComparison.Ordinal)); + } + + [Fact] + public async Task AddDurableTaskSchedulerSandboxActivitiesClient_UsesConfiguredDurableTaskClientInvoker() + { + // Arrange + RecordingOnDemandSandboxLogCallInvoker callInvoker = new(); + ServiceCollection services = new(); + services.AddOptions(Options.DefaultName) + .Configure(options => options.TaskHubName = "client-test-taskhub"); + services.AddOptions(Options.DefaultName) + .Configure(options => options.CallInvoker = callInvoker); + services.AddDurableTaskSchedulerSandboxActivitiesClient(); + + using ServiceProvider provider = services.BuildServiceProvider(); + SandboxActivitiesClient client = provider.GetRequiredService(); + + // Act + await client.RemoveSandboxWorkerProfileAsync("profile-a"); + + // Assert + callInvoker.RemoveRequest.Should().NotBeNull(); + callInvoker.RemoveRequest!.WorkerProfileId.Should().Be("profile-a"); + callInvoker.RemoveHeaders.Should().Contain(header => header.Key == "taskhub" && header.Value == "client-test-taskhub"); + } + + [Fact] + public async Task EnableSandboxActivitiesAsync_SendsWorkerProfileWorkerProfiles() + { + // Arrange + RecordingOnDemandSandboxLogCallInvoker callInvoker = new(); + SandboxActivitiesClient client = new( + new SandboxActivitiesGrpcTransport(new SandboxActivities.SandboxActivitiesClient(callInvoker)), + "client-test-taskhub", + new SandboxWorkerProfileProvider()); + + // Act + await client.EnableSandboxActivitiesAsync(); + + // Assert + SandboxWorkerProfile workerProfile = callInvoker.DeclareRequests + .Should() + .ContainSingle(request => request.WorkerProfileId == "client-test-profile") + .Subject; + workerProfile.Activities.Select(static activity => activity.Name).Should().Equal("ClientTestRemoteActivity"); + workerProfile.Activities.Select(static activity => activity.Version).Should().Equal("v1"); + workerProfile.Image.ImageRef.Should().Be("example.com/client-test-worker:latest"); + callInvoker.DeclareHeaders.Should().Contain(header => header.Key == "taskhub" && header.Value == "client-test-taskhub"); + callInvoker.UnaryDisposeCount.Should().BeGreaterThan(0); + } + + sealed class RecordingOnDemandSandboxLogCallInvoker : CallInvoker + { + public List DeclareRequests { get; } = []; + + public Metadata DeclareHeaders { get; private set; } = []; + + public RemoveSandboxWorkerProfileRequest? RemoveRequest { get; private set; } + + public Metadata RemoveHeaders { get; private set; } = []; + + public int UnaryDisposeCount { get; private set; } + + public override TResponse BlockingUnaryCall( + Method method, + string? host, + CallOptions options, + TRequest request) + { + throw new NotSupportedException(); + } + + public override AsyncUnaryCall AsyncUnaryCall( + Method method, + string? host, + CallOptions options, + TRequest request) + { + if (method.FullName.EndsWith("/DeclareSandboxWorkerProfile", StringComparison.Ordinal)) + { + this.DeclareRequests.Add(((SandboxWorkerProfile)(object)request).Clone()); + this.DeclareHeaders = options.Headers ?? []; + return CreateUnaryCall((TResponse)(object)new DeclareSandboxWorkerProfileResult()); + } + + if (method.FullName.EndsWith("/RemoveSandboxWorkerProfile", StringComparison.Ordinal)) + { + this.RemoveRequest = (RemoveSandboxWorkerProfileRequest)(object)request; + this.RemoveHeaders = options.Headers ?? []; + return CreateUnaryCall((TResponse)(object)new RemoveSandboxWorkerProfileResult()); + } + + throw new NotSupportedException(method.FullName); + } + + public override AsyncServerStreamingCall AsyncServerStreamingCall( + Method method, + string? host, + CallOptions options, + TRequest request) + { + throw new NotSupportedException(); + } + + public override AsyncClientStreamingCall AsyncClientStreamingCall( + Method method, + string? host, + CallOptions options) + { + throw new NotSupportedException(); + } + + public override AsyncDuplexStreamingCall AsyncDuplexStreamingCall( + Method method, + string? host, + CallOptions options) + { + throw new NotSupportedException(); + } + + AsyncUnaryCall CreateUnaryCall(TResponse response) + { + return new AsyncUnaryCall( + Task.FromResult(response), + Task.FromResult(new Metadata()), + () => new Status(StatusCode.OK, string.Empty), + () => new Metadata(), + () => this.UnaryDisposeCount++); + } + } + + static SandboxWorkerProfileOptions CreateWorkerProfileOptions() + { + SandboxWorkerProfileOptions options = new() + { + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + ContainerImage = "mcr.microsoft.com/durabletask/demo-worker:1.0", + ImagePullManagedIdentityClientId = "image-pull-client-id", + SchedulerManagedIdentityClientId = "scheduler-client-id", + Cpu = "500m", + Memory = "1024Mi", + MaxConcurrentActivities = 7, + }; + options.AddActivity("RemoteHello", version: null); + return options; + } + + [SandboxWorkerProfile("client-test-profile")] + sealed class ClientTestWorkerProfile : ISandboxWorkerProfile + { + public void Configure(SandboxWorkerProfileOptions options) + { + options.ContainerImage = "example.com/client-test-worker:latest"; + options.ImagePullManagedIdentityClientId = "image-pull-client-id"; + options.SchedulerManagedIdentityClientId = "scheduler-client-id"; + options.Cpu = "500m"; + options.Memory = "1024Mi"; + options.MaxConcurrentActivities = 4; + options.AddActivity("ClientTestRemoteActivity", "v1"); + } + } + + [SandboxWorkerProfile("annotated-profile")] + sealed class AnnotatedWorkerProfile : ISandboxWorkerProfile + { + public static int ConfigureCallCount { get; private set; } + + public void Configure(SandboxWorkerProfileOptions options) + { + ConfigureCallCount++; + options.ContainerImage = "example.com/repo/annotated-worker:latest"; + options.ImagePullManagedIdentityClientId = "image-pull-client-id"; + options.SchedulerManagedIdentityClientId = "scheduler-client-id"; + options.Cpu = "500m"; + options.Memory = "1024Mi"; + options.MaxConcurrentActivities = 4; + options.EnvironmentVariables["CUSTOM_ENV"] = "configured-value"; + options.AddActivity("ConfiguredRemoteHello", "v1"); + } + } + + [SandboxWorkerProfile("case-profile-a")] + sealed class CaseDuplicateWorkerProfileA : ISandboxWorkerProfile + { + public void Configure(SandboxWorkerProfileOptions options) + { + if (Environment.GetEnvironmentVariable("DTS_TEST_ENABLE_CASE_DUPLICATE_SANDBOX_PROFILES") == "true") + { + options.AddActivity("CaseActivity", version: null); + } + } + } + + [SandboxWorkerProfile("case-profile-b")] + sealed class CaseDuplicateWorkerProfileB : ISandboxWorkerProfile + { + public void Configure(SandboxWorkerProfileOptions options) + { + if (Environment.GetEnvironmentVariable("DTS_TEST_ENABLE_CASE_DUPLICATE_SANDBOX_PROFILES") == "true") + { + options.AddActivity("caseactivity", version: null); + } + } + } + + sealed class ProfileWithoutInterface + { + } + + sealed class EnvironmentVariableScope : IDisposable + { + readonly string name; + readonly string? originalValue; + + public EnvironmentVariableScope(string name, string? value) + { + this.name = name; + this.originalValue = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() => Environment.SetEnvironmentVariable(this.name, this.originalValue); + } +} diff --git a/test/Shared/AzureManaged.Tests/Shared.AzureManaged.Tests.csproj b/test/Shared/AzureManaged.Tests/Shared.AzureManaged.Tests.csproj index 4599449b..5d7f0abd 100644 --- a/test/Shared/AzureManaged.Tests/Shared.AzureManaged.Tests.csproj +++ b/test/Shared/AzureManaged.Tests/Shared.AzureManaged.Tests.csproj @@ -17,6 +17,7 @@ + diff --git a/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs new file mode 100644 index 00000000..146d142b --- /dev/null +++ b/test/Worker/AzureManaged.Tests/SandboxActivitiesTests.cs @@ -0,0 +1,1131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Identity; +using FluentAssertions; +using Grpc.Core; +using Microsoft.DurableTask.AzureManaged.Internal; +using Microsoft.DurableTask.Protobuf.Sandboxes; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.DurableTask.Worker.AzureManaged.Sandboxes; +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 SandboxActivitiesTests +{ + const string TaskHub = "testhub"; + + [Fact] + public async Task SandboxActivitiesGrpcTransport_SendsTaskHubMetadata() + { + // Arrange + RecordingSandboxActivitiesCallInvoker callInvoker = new(); + SandboxActivitiesGrpcTransport transport = new(new SandboxActivities.SandboxActivitiesClient(callInvoker)); + SandboxWorkerProfile workerProfile = new() + { + WorkerProfileId = "profile-a", + Image = new SandboxActivityImage + { + ImageRef = "example.com/repo/worker:latest", + }, + Resources = new SandboxActivityResources + { + Cpu = "500m", + Memory = "1024Mi", + }, + MaxConcurrentActivities = 7, + }; + workerProfile.Activities.Add(new SandboxActivity { Name = "RemoteHello" }); + + // Act + await transport.DeclareSandboxWorkerProfileAsync(workerProfile, TaskHub, CancellationToken.None); + await using ISandboxActivityWorkerSession session = transport.OpenSandboxActivityWorkerSession( + TaskHub, + CancellationToken.None); + + // Assert + callInvoker.WorkerProfileHeaders.Should().Contain(header => header.Key == "taskhub" && header.Value == TaskHub); + callInvoker.WorkerSessionHeaders.Should().Contain(header => header.Key == "taskhub" && header.Value == TaskHub); + } + + [Fact] + public async Task SandboxActivitiesGrpcTransport_CanRelyOnChannelTaskHubMetadata() + { + // Arrange + RecordingSandboxActivitiesCallInvoker callInvoker = new(); + SandboxActivitiesGrpcTransport transport = new( + new SandboxActivities.SandboxActivitiesClient(callInvoker), + attachTaskHubMetadata: false); + SandboxWorkerProfile workerProfile = new() + { + WorkerProfileId = "profile-a", + Image = new SandboxActivityImage + { + ImageRef = "example.com/repo/worker:latest", + }, + Resources = new SandboxActivityResources + { + Cpu = "500m", + Memory = "1024Mi", + }, + MaxConcurrentActivities = 7, + }; + workerProfile.Activities.Add(new SandboxActivity { Name = "RemoteHello" }); + + // Act + await transport.DeclareSandboxWorkerProfileAsync(workerProfile, TaskHub, CancellationToken.None); + await using ISandboxActivityWorkerSession session = transport.OpenSandboxActivityWorkerSession( + TaskHub, + CancellationToken.None); + + // Assert + callInvoker.WorkerProfileHeaders.Should().NotContain(header => header.Key == "taskhub"); + callInvoker.WorkerSessionHeaders.Should().NotContain(header => header.Key == "taskhub"); + } + + [Fact] + public async Task SandboxActivityWorkerRegistrationHostedService_SendsLiveWorkerMetadataWithRegisteredActivities() + { + // Arrange + string? originalSandboxProvider = Environment.GetEnvironmentVariable("DTS_SANDBOX_PROVIDER"); + string? originalSandboxId = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID"); + Environment.SetEnvironmentVariable("DTS_SANDBOX_PROVIDER", "Sandbox"); + Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", "sandbox-1"); + + try + { + SandboxWorkerRuntimeOptions options = new() + { + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + HeartbeatInterval = TimeSpan.FromDays(1), + }; + FakeSandboxActivitiesTransport client = new(); + SandboxActivityWorkerRegistrationHostedService service = new( + client, + options, + Activities("RemoteHello"), + NullLogger.Instance); + + // 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); + SandboxActivityWorkerMessage message = client.Session.Messages.Should().ContainSingle().Subject; + SandboxActivityWorkerStart start = message.Start; + start.TaskHub.Should().Be(TaskHub); + start.WorkerProfileId.Should().Be("profile-a"); + start.MaxActivitiesCount.Should().Be(3); + start.SandboxProvider.Should().Be(SandboxProviderKind.Sandbox); + start.DtsSandboxIdentifier.Should().Be("sandbox-1"); + start.Activities.Select(static activity => activity.Name).Should().Equal("RemoteHello"); + start.Activities.Select(static activity => activity.Version).Should().Equal(string.Empty); + } + finally + { + Environment.SetEnvironmentVariable("DTS_SANDBOX_PROVIDER", originalSandboxProvider); + Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", originalSandboxId); + } + } + + [Fact] + public void SandboxWorkerMessageBuilder_NormalizesTaskHubAndSandboxId() + { + // Arrange + string? originalSandboxId = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID"); + Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", " sandbox-1 "); + + try + { + SandboxWorkerRuntimeOptions options = new() + { + TaskHub = " testhub ", + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + }; + + // Act + SandboxActivityWorkerMessage message = SandboxWorkerMessageBuilder.BuildWorkerStart( + options, + Activities("RemoteHello")); + + // Assert + SandboxActivityWorkerStart start = message.Start; + start.TaskHub.Should().Be(TaskHub); + start.DtsSandboxIdentifier.Should().Be("sandbox-1"); + } + finally + { + Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", originalSandboxId); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SandboxWorkerMessageBuilder_MissingSandboxId_Throws(string? sandboxId) + { + // Arrange + string? originalSandboxId = Environment.GetEnvironmentVariable("DTS_SANDBOX_ID"); + Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", sandboxId); + + try + { + SandboxWorkerRuntimeOptions options = new() + { + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + }; + + // Act + Action action = () => SandboxWorkerMessageBuilder.BuildWorkerStart( + options, + Activities("RemoteHello")); + + // Assert + action.Should().Throw() + .WithMessage("On-demand sandbox activity worker registration requires a DTS sandbox ID."); + } + finally + { + Environment.SetEnvironmentVariable("DTS_SANDBOX_ID", originalSandboxId); + } + } + + [Fact] + public void SandboxActivityTracker_TracksInFlightActivityCount() + { + // Arrange + SandboxActivityTracker 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 SandboxActivityWorkerRegistrationHostedService_SendsHeartbeatWithCurrentInFlightCount() + { + // Arrange + using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); + SandboxWorkerRuntimeOptions options = new() + { + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + HeartbeatInterval = TimeSpan.FromMilliseconds(10), + }; + + FakeSandboxActivitiesTransport client = new(); + SandboxActivityTracker activityTracker = new(); + activityTracker.NotifyActivityStarted(); + activityTracker.NotifyActivityStarted(); + + SandboxActivityWorkerRegistrationHostedService service = new( + client, + options, + Activities("RemoteHello"), + NullLogger.Instance, + activityTracker: 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 SandboxActivityWorkerRegistrationHostedService_ReopensSessionAfterTransientStreamFailure() + { + // Arrange + using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); + SandboxWorkerRuntimeOptions options = new() + { + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + HeartbeatInterval = TimeSpan.FromMilliseconds(10), + WorkerRegistrationRetryInitialDelay = TimeSpan.FromMilliseconds(10), + WorkerRegistrationRetryMaxDelay = TimeSpan.FromMilliseconds(10), + }; + + FakeSandboxActivityWorkerSession failedSession = new() { ThrowOnWriteAttempt = 2 }; + FakeSandboxActivityWorkerSession recoveredSession = new(); + FakeSandboxActivitiesTransport client = new(); + client.QueueSession(failedSession); + client.QueueSession(recoveredSession); + + SandboxActivityWorkerRegistrationHostedService service = new( + client, + options, + Activities("RemoteHello"), + 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 SandboxActivityWorkerRegistrationHostedService_ReopensSessionAfterTerminalServerFailure() + { + // Arrange + using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); + SandboxWorkerRuntimeOptions options = new() + { + TaskHub = $" {TaskHub} ", + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + HeartbeatInterval = TimeSpan.FromDays(1), + WorkerRegistrationRetryInitialDelay = TimeSpan.FromMilliseconds(10), + WorkerRegistrationRetryMaxDelay = TimeSpan.FromMilliseconds(10), + }; + + FakeSandboxActivityWorkerSession failedSession = new(); + FakeSandboxActivityWorkerSession recoveredSession = new(); + FakeSandboxActivitiesTransport client = new(); + client.QueueSession(failedSession); + client.QueueSession(recoveredSession); + + SandboxActivityWorkerRegistrationHostedService service = new( + client, + options, + Activities("RemoteHello"), + 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 SandboxActivityWorkerRegistrationHostedService_DoesNotResetBackoffAfterStartMessageOnly() + { + // Arrange + using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); + SandboxWorkerRuntimeOptions options = new() + { + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + HeartbeatInterval = TimeSpan.FromDays(1), + WorkerRegistrationRetryInitialDelay = TimeSpan.FromMilliseconds(250), + WorkerRegistrationRetryMaxDelay = TimeSpan.FromSeconds(1), + }; + + FakeSandboxActivityWorkerSession firstFailedSession = new(); + FakeSandboxActivityWorkerSession secondFailedSession = new(); + FakeSandboxActivityWorkerSession recoveredSession = new(); + FakeSandboxActivitiesTransport client = new(); + client.QueueSession(firstFailedSession); + client.QueueSession(secondFailedSession); + client.QueueSession(recoveredSession); + + SandboxActivityWorkerRegistrationHostedService service = new( + client, + options, + Activities("RemoteHello"), + NullLogger.Instance, + reconnectJitter: new DeterministicRandom(0.999999)); + + // Act + await service.StartAsync(CancellationToken.None); + await firstFailedSession.WaitForMessageAsync(message => message.Start != null); + firstFailedSession.FailCompletion(new RpcException(new Status(StatusCode.Unavailable, "terminal-1"))); + await secondFailedSession.WaitForMessageAsync(message => message.Start != null); + secondFailedSession.FailCompletion(new RpcException(new Status(StatusCode.Unavailable, "terminal-2"))); + Task recoveredStartTask = recoveredSession.WaitForMessageAsync(message => message.Start != null); + Task completedTooEarly = await Task.WhenAny( + recoveredStartTask, + Task.Delay(TimeSpan.FromMilliseconds(375))); + await recoveredStartTask.WaitAsync(TimeSpan.FromSeconds(5)); + await service.StopAsync(CancellationToken.None); + + // Assert + completedTooEarly.Should().NotBe(recoveredStartTask); + client.SessionTaskHubs.Should().Equal(TaskHub, TaskHub, TaskHub); + } + + [Fact] + public void SandboxActivityWorkerRegistrationHostedService_ComputeJitteredReconnectDelay_UsesFullJitterWindow() + { + // Arrange + TimeSpan retryDelay = TimeSpan.FromSeconds(10); + + // Act + TimeSpan zero = SandboxActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( + TimeSpan.Zero, + new DeterministicRandom(0.5)); + TimeSpan low = SandboxActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( + retryDelay, + new DeterministicRandom(0.0)); + TimeSpan mid = SandboxActivityWorkerRegistrationHostedService.ComputeJitteredReconnectDelay( + retryDelay, + new DeterministicRandom(0.5)); + TimeSpan high = SandboxActivityWorkerRegistrationHostedService.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 void SandboxActivityWorkerRegistrationHostedService_GetNextRetryDelay_SaturatesBeforeTickOverflow() + { + // Arrange + // Act + TimeSpan nextRetryDelay = SandboxActivityWorkerRegistrationHostedService.ComputeNextRetryDelay( + TimeSpan.FromTicks((TimeSpan.MaxValue.Ticks / 2) + 1), + TimeSpan.MaxValue); + + // Assert + nextRetryDelay.Should().Be(TimeSpan.MaxValue); + } + + [Fact] + public async Task SandboxActivityWorkerRegistrationHostedService_AppliesJitterToReconnectDelay() + { + // Arrange + using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); + SandboxWorkerRuntimeOptions options = new() + { + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + HeartbeatInterval = TimeSpan.FromMilliseconds(10), + WorkerRegistrationRetryInitialDelay = TimeSpan.FromDays(1), + WorkerRegistrationRetryMaxDelay = TimeSpan.FromDays(1), + }; + + FakeSandboxActivityWorkerSession failedSession = new() { ThrowOnWriteAttempt = 2 }; + FakeSandboxActivityWorkerSession recoveredSession = new(); + FakeSandboxActivitiesTransport client = new(); + client.QueueSession(failedSession); + client.QueueSession(recoveredSession); + + SandboxActivityWorkerRegistrationHostedService service = new( + client, + options, + Activities("RemoteHello"), + 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 SandboxActivityWorkerRegistrationHostedService_StopAsync_DoesNotCompleteStreamWhileWriteIsInFlight() + { + // Arrange + using EnvironmentVariableScope sandboxId = new("DTS_SANDBOX_ID", "sandbox-1"); + SandboxWorkerRuntimeOptions options = new() + { + TaskHub = TaskHub, + WorkerProfileId = "profile-a", + MaxConcurrentActivities = 3, + HeartbeatInterval = TimeSpan.FromMilliseconds(10), + }; + + FakeSandboxActivityWorkerSession session = new() { BlockWriteAttempt = 2 }; + FakeSandboxActivitiesTransport client = new(); + client.QueueSession(session); + + SandboxActivityWorkerRegistrationHostedService service = new( + client, + options, + Activities("RemoteHello"), + 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 UseSandboxWorker_ConfiguresRegisteredActivityWorkerFilter() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); + using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); + using EnvironmentVariableScope sandboxProvider = new("DTS_SANDBOX_PROVIDER", "Sandbox"); + using EnvironmentVariableScope maxActivities = new("DTS_SANDBOX_MAX_ACTIVITIES", "3"); + 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); + + // Act + mockBuilder.Object.UseSandboxWorker(); + + await using ServiceProvider provider = services.BuildServiceProvider(); + DurableTaskWorkerWorkItemFilters filters = provider.GetRequiredService>().Get(Options.DefaultName); + DurableTaskWorkerOptions workerOptions = provider.GetRequiredService>().Get(Options.DefaultName); + + // Assert + filters.Activities.Select(filter => filter.Name).Should().Equal("RemoteHello"); + filters.Orchestrations.Should().BeEmpty(); + filters.Entities.Should().BeEmpty(); + workerOptions.Concurrency.MaximumConcurrentActivityWorkItems.Should().Be(3); + workerOptions.Concurrency.MaximumConcurrentOrchestrationWorkItems.Should().Be(0); + workerOptions.Concurrency.MaximumConcurrentEntityWorkItems.Should().Be(0); + } + + [Theory] + [InlineData("")] + [InlineData("0")] + [InlineData("-1")] + [InlineData("many")] + public async Task UseSandboxWorker_InvalidMaxActivities_ThrowsWhenWorkerOptionsAreResolved(string maxActivitiesValue) + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); + using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); + using EnvironmentVariableScope sandboxProvider = new("DTS_SANDBOX_PROVIDER", "Sandbox"); + using EnvironmentVariableScope maxActivities = new("DTS_SANDBOX_MAX_ACTIVITIES", maxActivitiesValue); + ServiceCollection services = new(); + services.Configure( + Options.DefaultName, + registry => registry.AddActivityFunc(new TaskName("RemoteHello"), (_, input) => input)); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + mockBuilder.Object.UseSandboxWorker(); + await using ServiceProvider provider = services.BuildServiceProvider(); + + // Act + Action action = () => provider + .GetRequiredService>() + .Get(Options.DefaultName); + + // Assert + action.Should().Throw() + .WithMessage("DTS_SANDBOX_MAX_ACTIVITIES must be a positive integer when injected by DTS for on-demand sandbox workers."); + } + + [Fact] + public async Task UseSandboxWorker_WithNoRegisteredActivities_FailsWhenWorkerFiltersAreResolved() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); + using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); + using EnvironmentVariableScope sandboxProvider = new("DTS_SANDBOX_PROVIDER", "Sandbox"); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + mockBuilder.Object.UseSandboxWorker(); + + await using ServiceProvider provider = services.BuildServiceProvider(); + + // Act + Action act = () => provider + .GetRequiredService>() + .Get(Options.DefaultName); + + // Assert + act.Should().Throw() + .WithMessage("On-demand sandbox workers require at least one registered activity*"); + } + + [Fact] + public async Task UseSandboxWorker_ConfiguresSchedulerWithManagedIdentityCredential() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); + using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + // Act + mockBuilder.Object.UseSandboxWorker(); + + await using ServiceProvider provider = services.BuildServiceProvider(); + DurableTaskSchedulerWorkerOptions options = provider + .GetRequiredService>() + .Get(Options.DefaultName); + + // Assert + options.EndpointAddress.Should().Be("https://example.scheduler"); + options.TaskHubName.Should().Be(TaskHub); + options.Credential.Should().BeOfType(); + options.AllowInsecureCredentials.Should().BeFalse(); + } + + [Fact] + public void UseSandboxWorker_MissingAuthentication_Throws() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", null); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + // Act + Action action = () => mockBuilder.Object.UseSandboxWorker(); + + // Assert + action.Should().Throw() + .WithMessage("DTS_AUTHENTICATION must be injected by DTS for on-demand sandbox workers."); + } + + [Fact] + public async Task UseSandboxWorker_InvalidAuthentication_ThrowsWhenSchedulerOptionsAreResolved() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentty"); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + mockBuilder.Object.UseSandboxWorker(); + await using ServiceProvider provider = services.BuildServiceProvider(); + + // Act + Action action = () => provider + .GetRequiredService>() + .Get(Options.DefaultName); + + // Assert + action.Should().Throw() + .WithMessage("DTS_AUTHENTICATION must be 'ManagedIdentity' for on-demand sandbox workers."); + } + + [Fact] + public async Task UseSandboxWorker_WithManagedIdentityAuth_ConfiguresSchedulerCredential() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); + using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + // Act + mockBuilder.Object.UseSandboxWorker(); + + await using ServiceProvider provider = services.BuildServiceProvider(); + DurableTaskSchedulerWorkerOptions options = provider + .GetRequiredService>() + .Get(Options.DefaultName); + + // Assert + options.EndpointAddress.Should().Be("https://example.scheduler"); + options.TaskHubName.Should().Be(TaskHub); + options.Credential.Should().BeOfType(); + options.AllowInsecureCredentials.Should().BeFalse(); + } + + [Fact] + public async Task UseSandboxWorker_WithManagedIdentityAuthAndMissingClientId_Throws() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); + using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", null); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + // Act + mockBuilder.Object.UseSandboxWorker(); + await using ServiceProvider provider = services.BuildServiceProvider(); + Action getOptions = () => provider + .GetRequiredService>() + .Get(Options.DefaultName); + + // Assert + getOptions.Should().Throw() + .WithMessage("*DTS_UMI_CLIENT_ID*"); + } + + [Fact] + public void UseSandboxWorker_DoesNotRegisterWakeupServerHostedService() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + // Act + mockBuilder.Object.UseSandboxWorker(); + + // Assert + services.Count(descriptor => descriptor.ServiceType == typeof(IHostedService)).Should().Be(1); + } + + [Fact] + public void UseSandboxWorker_MissingInjectedEndpoint_Throws() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", null); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); + ServiceCollection services = new(); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + // Act + Action action = () => mockBuilder.Object.UseSandboxWorker(); + + // Assert + action.Should().Throw() + .WithMessage("DTS_ENDPOINT must be injected by DTS for on-demand sandbox workers."); + } + + [Fact] + public void UseSandboxWorker_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.UseSandboxWorker(); + + // Assert + action.Should().Throw() + .WithMessage("DTS_TASK_HUB must be injected by DTS for on-demand sandbox workers."); + } + + [Fact] + public async Task UseSandboxWorker_MissingInjectedSandboxProvider_ThrowsWhenWorkerOptionsAreResolved() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); + using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); + using EnvironmentVariableScope sandboxProvider = new("DTS_SANDBOX_PROVIDER", null); + ServiceCollection services = new(); + services.Configure( + Options.DefaultName, + registry => registry.AddActivityFunc(new TaskName("RemoteHello"), (_, input) => input)); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + mockBuilder.Object.UseSandboxWorker(); + await using ServiceProvider provider = services.BuildServiceProvider(); + + // Act + Action action = () => provider + .GetRequiredService>() + .Get(Options.DefaultName); + + // Assert + action.Should().Throw() + .WithMessage("DTS_SANDBOX_PROVIDER must be injected by DTS for on-demand sandbox workers."); + } + + [Fact] + public async Task UseSandboxWorker_InvalidInjectedSandboxProvider_ThrowsWhenWorkerOptionsAreResolved() + { + // Arrange + using EnvironmentVariableScope endpoint = new("DTS_ENDPOINT", "https://example.scheduler"); + using EnvironmentVariableScope taskHub = new("DTS_TASK_HUB", TaskHub); + using EnvironmentVariableScope workerProfile = new("DTS_WORKER_PROFILE_ID", "profile-a"); + using EnvironmentVariableScope auth = new("DTS_AUTHENTICATION", "ManagedIdentity"); + using EnvironmentVariableScope clientId = new("DTS_UMI_CLIENT_ID", "worker-client-id"); + using EnvironmentVariableScope sandboxProvider = new("DTS_SANDBOX_PROVIDER", "ContainerApp"); + ServiceCollection services = new(); + services.Configure( + Options.DefaultName, + registry => registry.AddActivityFunc(new TaskName("RemoteHello"), (_, input) => input)); + Mock mockBuilder = new(); + mockBuilder.Setup(builder => builder.Services).Returns(services); + mockBuilder.Setup(builder => builder.Name).Returns(Options.DefaultName); + + mockBuilder.Object.UseSandboxWorker(); + await using ServiceProvider provider = services.BuildServiceProvider(); + + // Act + Action action = () => provider + .GetRequiredService>() + .Get(Options.DefaultName); + + // Assert + action.Should().Throw() + .WithMessage("DTS_SANDBOX_PROVIDER must be 'Sandbox' or 'AcaSessionPool' for on-demand sandbox workers."); + } + + static IReadOnlyCollection Activities(params string[] names) => + names.Select(static name => new SandboxActivityMetadata.Activity(name, Version: null)).ToArray(); + + sealed class FakeSandboxActivitiesTransport : ISandboxActivitiesTransport + { + readonly Queue queuedSessions = new(); + + public List SessionTaskHubs { get; } = []; + + public List Sessions { get; } = []; + + public FakeSandboxActivityWorkerSession Session { get; } = new(); + + public void QueueSession(FakeSandboxActivityWorkerSession session) => this.queuedSessions.Enqueue(session); + + public Task DeclareSandboxWorkerProfileAsync( + SandboxWorkerProfile workerProfile, + string taskHub, + CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public Task RemoveSandboxWorkerProfileAsync( + string workerProfileId, + string taskHub, + CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public ISandboxActivityWorkerSession OpenSandboxActivityWorkerSession(string taskHub, CancellationToken cancellationToken) + { + this.SessionTaskHubs.Add(taskHub); + FakeSandboxActivityWorkerSession session = this.queuedSessions.Count > 0 + ? this.queuedSessions.Dequeue() + : this.Session; + this.Sessions.Add(session); + return session; + } + } + + sealed class RecordingSandboxActivitiesCallInvoker : CallInvoker + { + public Metadata WorkerProfileHeaders { 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("/DeclareSandboxWorkerProfile"); + this.WorkerProfileHeaders = options.Headers ?? []; + + return new AsyncUnaryCall( + Task.FromResult((TResponse)(object)new DeclareSandboxWorkerProfileResult()), + 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("/ConnectSandboxActivityWorker"); + this.WorkerSessionHeaders = options.Headers ?? []; + + return new AsyncClientStreamingCall( + new RecordingClientStreamWriter(), + Task.FromResult((TResponse)(object)new SandboxActivityWorkerSessionResult()), + 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 FakeSandboxActivityWorkerSession : ISandboxActivityWorkerSession + { + 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)); + while (!timeout.IsCancellationRequested) + { + lock (this.sync) + { + if (this.Messages.Any(predicate)) + { + return; + } + } + + await Task.Delay(TimeSpan.FromMilliseconds(10)); + } + + throw new TimeoutException("Timed out waiting for on-demand sandbox worker message."); + } + + public Task WriteMessageAsync(SandboxActivityWorkerMessage message) + { + int attempt; + bool blockWrite; + lock (this.sync) + { + attempt = ++this.writeAttempts; + if (this.ThrowOnWriteAttempt == attempt) + { + throw new RpcException(new Status(StatusCode.Unavailable, "transient")); + } + + this.activeWrites++; + blockWrite = this.BlockWriteAttempt == attempt; + if (blockWrite) + { + this.blockedWriteStarted.TrySetResult(); + } + } + + return this.WriteMessageCoreAsync(message, blockWrite); + } + + 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 SandboxActivityWorkerSessionResult()); + await this.completion.Task.ConfigureAwait(false); + } + + public ValueTask DisposeAsync() => default; + + async Task WriteMessageCoreAsync(SandboxActivityWorkerMessage 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 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; + readonly string? originalValue; + + public EnvironmentVariableScope(string name, string? value) + { + this.name = name; + this.originalValue = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() => Environment.SetEnvironmentVariable(this.name, this.originalValue); + } +} diff --git a/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj b/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj index 9aab6f15..fe1183c3 100644 --- a/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj +++ b/test/Worker/AzureManaged.Tests/Worker.AzureManaged.Tests.csproj @@ -5,11 +5,13 @@ + + diff --git a/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs b/test/Worker/Grpc.Tests/GrpcDurableTaskWorkerTests.cs index 4c78aac0..ff641f0a 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; @@ -9,6 +10,7 @@ using Microsoft.DurableTask.Tests.Logging; using Microsoft.DurableTask.Worker; using Microsoft.DurableTask.Worker.Grpc.Internal; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using P = Microsoft.DurableTask.Protobuf; @@ -28,6 +30,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 +255,111 @@ 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 = CreateActivityWorker(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 DispatchWorkItem_ActivityRequest_NotificationFailure_CompletesActivity() + { + // Arrange + TaskCompletionSource activityCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); + GrpcDurableTaskWorkerOptions grpcOptions = new(); + grpcOptions.ConfigureActivityNotification(phase => throw new InvalidOperationException($"Notification failed: {phase}")); + + P.WorkItem activityWorkItem = new() + { + ActivityRequest = new P.ActivityRequest + { + Name = "MyActivity", + TaskId = 42, + OrchestrationInstance = new P.OrchestrationInstance + { + InstanceId = "instance1", + ExecutionId = "execution1", + }, + }, + CompletionToken = "completion1", + }; + + DurableTaskWorkerOptions workerOptions = new() + { + Logging = { UseLegacyCategories = false }, + }; + TestLogProvider logProvider = new(new NullOutput()); + GrpcDurableTaskWorker worker = CreateActivityWorker(grpcOptions, workerOptions, new SimpleLoggerFactory(logProvider)); + Mock clientMock = new( + MockBehavior.Strict, + new object[] { Mock.Of() }); + clientMock + .Setup(client => client.CompleteActivityTaskAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback(() => activityCompleted.TrySetResult()) + .Returns(CreateUnaryCall(Task.FromResult(new P.CompleteTaskResponse()))); + object processor = CreateProcessor(worker, clientMock.Object); + + // Act + InvokeDispatchWorkItem(processor, activityWorkItem, CancellationToken.None); + await activityCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Assert + clientMock.VerifyAll(); + logProvider.TryGetLogs(Category, out IReadOnlyCollection? logs).Should().BeTrue(); + logs!.Should().Contain(log => log.Message.Contains("Activity notification callback failed for phase 'Started'")); + logs.Should().Contain(log => log.Message.Contains("Activity notification callback failed for phase 'Completed'")); + } + [Fact] public async Task ProcessorExecuteAsync_HelloDeadlineExceeded_ReturnsChannelRecreateRequested() { @@ -570,19 +680,74 @@ static GrpcDurableTaskWorker CreateWorker( DurableTaskWorkerOptions workerOptions, ILoggerFactory loggerFactory, IDurableTaskFactory factory) + { + return CreateWorker(grpcOptions, workerOptions, loggerFactory, factory, Mock.Of()); + } + + static GrpcDurableTaskWorker CreateActivityWorker(GrpcDurableTaskWorkerOptions grpcOptions) + { + return CreateActivityWorker(grpcOptions, new DurableTaskWorkerOptions(), NullLoggerFactory.Instance); + } + + static GrpcDurableTaskWorker CreateActivityWorker( + GrpcDurableTaskWorkerOptions grpcOptions, + DurableTaskWorkerOptions workerOptions, + ILoggerFactory loggerFactory) + { + Mock factoryMock = new(MockBehavior.Strict); + factoryMock + .Setup(factory => factory.TryCreateActivity( + It.Is(name => name.Name == "MyActivity"), + It.IsAny(), + out It.Ref.IsAny)) + .Returns((TaskName name, IServiceProvider serviceProvider, out ITaskActivity? activity) => + { + activity = new TestActivity(); + return true; + }); + + factoryMock + .Setup(factory => factory.TryCreateOrchestrator( + It.IsAny(), + It.IsAny(), + out It.Ref.IsAny)) + .Returns(false); + + ServiceProvider services = new ServiceCollection().BuildServiceProvider(); + return CreateWorker(grpcOptions, workerOptions, loggerFactory, factoryMock.Object, services); + } + + static GrpcDurableTaskWorker CreateWorker( + GrpcDurableTaskWorkerOptions grpcOptions, + DurableTaskWorkerOptions workerOptions, + ILoggerFactory loggerFactory, + IDurableTaskFactory factory, + IServiceProvider services) { return new GrpcDurableTaskWorker( name: "Test", factory: factory, grpcOptions: new OptionsMonitorStub(grpcOptions), workerOptions: new OptionsMonitorStub(workerOptions), - services: Mock.Of(), + services: services, loggerFactory: loggerFactory, orchestrationFilter: null, exceptionPropertiesProvider: null, workItemFiltersMonitor: null); } + sealed class TestActivity : ITaskActivity + { + public System.Type InputType => typeof(object); + + public System.Type OutputType => typeof(object); + + public Task RunAsync(TaskActivityContext context, object? input) + { + return Task.FromResult(input); + } + } + static Task InvokeExecuteAsync(GrpcDurableTaskWorker worker, CancellationToken cancellationToken) { return (Task)ExecuteAsyncMethod.Invoke(worker, new object?[] { cancellationToken })!; @@ -613,6 +778,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,