diff --git a/.github/ISSUE_TEMPLATE/new-release-template.md b/.github/ISSUE_TEMPLATE/new-release-template.md index 1a52e32a1..728ef7cb7 100644 --- a/.github/ISSUE_TEMPLATE/new-release-template.md +++ b/.github/ISSUE_TEMPLATE/new-release-template.md @@ -16,13 +16,21 @@ _Due: <2-3-business-days-before-release>_ - [ ] Publish DTFx packages to the [ADO feed](https://dev.azure.com/durabletaskframework/Durable%20Task%20Framework%20CI/_artifacts/feed/durabletask) for testing. - [ ] Keep branch `azure-storage-v12` updated with branch `main`. +**Prep DotNet Isolated SDK Release: (assigned to:)** +_Due: <2-3-business-days-before-release>_ +- [ ] If there were DTFx.Core changes, check its reference version [here](https://github.com/microsoft/durabletask-dotnet/blob/c838535adb6aedb6671cf193389ce63a6b4a9b24/src/Abstractions/Abstractions.csproj#L10). If updates are required, document the changes in [release notes](https://github.com/microsoft/durabletask-dotnet/blob/c838535adb6aedb6671cf193389ce63a6b4a9b24/src/Abstractions/RELEASENOTES.md). +- [ ] Check dotnet isolated SDK versions [here](https://github.com/microsoft/durabletask-dotnet/blob/c838535adb6aedb6671cf193389ce63a6b4a9b24/eng/targets/Release.props#L20). If updated, document the changes in the [change logs](https://github.com/microsoft/durabletask-dotnet/blob/c838535adb6aedb6671cf193389ce63a6b4a9b24/CHANGELOG.md). +- [ ] Run pipeline [Release .Net out-of-proc SDK](https://durabletaskframework.visualstudio.com/Durable%20Task%20Framework%20CI/_build?definitionId=29) to create the new package and publish it to the ADO feed for testing. + **Prep Release (assigned to: )** _Due: <2-business-days-before-release>_ -- [ ] Update Durable Functions references (Analyzer? DTFx?) and check current version. +- [ ] Update DTFx packages and Analyzer versions at WebJobs.Extensions.Durabletask.csproj and check if we need to update the WebJobs.Extensions.Durabletask version. - [ ] Locally, run `dotnet list package --vulnerable` to ensure the release is not affected by any vulnerable dependencies. - [ ] Review the [Dependabot vulnerability alerts](https://github.com/Azure/azure-functions-durable-extension/security/dependabot) and address them. Note: code samples / test projects _may_ be excluded from this check. - [ ] Add the Durable Functions package to the [ADO test feed](https://dev.azure.com/durabletaskframework/Durable%20Task%20Framework%20CI/_artifacts/feed/durabletask-test). - [ ] Check for package size, make sure it's not surprisingly heavier than a previous release. +- [ ] Update .NET Isolated SDK version at Worker.Extensions.Durabletask.csproj and check if we need to update the Worker.Extensions.Durabletask version. +- [ ] Run pipeline [Release .Net Isolated Worker Extension](https://durabletaskframework.visualstudio.com/Durable%20Task%20Framework%20CI/_build?definitionId=30) to create the new package and add it to the ADO feed for testing. - [ ] Merge (**choose create a merge commit, NOT squash merge**) dev into main. Person performing validation must approve PR. - [ ] Keep branch `v3.x` updated with branch `dev`. Do not merge PRs that are specific to Durable Functions v2. @@ -30,6 +38,8 @@ _Due: <2-business-days-before-release>_ _Due: <1-business-days-before-release>_ - [ ] Run private performance tests and ensure no regressions. **(assigned to: )** - [ ] Smoke test Functions V1, Functions V2, and Functions V3 .NET apps. **(assigned to: )** +- [ ] Smoke test .NET apps with backend Netherite, MSSQL. **(assigned to: )** +- [ ] Smoke test .NET isolated apps. **(assigned to: )** **DTFx Release Completion (assigned to: )** _Due: _ @@ -38,12 +48,18 @@ _Due: _ - [ ] Publish release notes for DTFx. - [ ] Patch increment DTFx packages that were released (either DT-AzureStorage only or if there were Core changes DT-AzureStorage, DT-Core, and DT-ApplicationInsights) +**DotNet Isolated SDK Release Completion: (assigned to:)** +_Due: _ +- [ ] Upload .NET isolated SDK packages to NuGet (directly to nuget.org). +- [ ] Publish release notes in the durable-dotnet repo. + **Release Completion (assigned to: )** _Due: _ - [ ] Delete Durable Functions packages from the [ADO test feed](https://dev.azure.com/durabletaskframework/Durable%20Task%20Framework%20CI/_artifacts/feed/durabletask-test). - [ ] Run the [Durable Functions release pipeline](https://dev.azure.com/durabletaskframework/Durable%20Task%20Framework%20CI/_build?definitionId=23) and select `main` as the branch. - [ ] Add the Durable Functions package to the [ADO feed](https://dev.azure.com/durabletaskframework/Durable%20Task%20Framework%20CI/_artifacts/feed/durabletask) using [this pipeline](https://dev.azure.com/durabletaskframework/Durable%20Task%20Framework%20CI/_release?_a=releases&view=mine&definitionId=11). - [ ] Upload the Durable Functions package to NuGet (directly to nuget.org). +- [ ] Upload .NET Isolated worker extension package to NuGet (directly to nuget.org). - [ ] Create a PR in the [Azure Functions templates repo](https://github.com/Azure/azure-functions-templates) targeting branch `dev` to update all references of "Microsoft.Azure.WebJobs.Extensions.DurableTask" (search for this string in the code) to the latest version. - [ ] _if and only if this is a new major release_, Create a PR in the [Azure Functions bundles repo](https://github.com/Azure/azure-functions-extension-bundles) to update bundles to the latest version . - [ ] Merge all pending PR docs from `pending_docs.md.` diff --git a/.github/workflows/smoketest-dotnet-isolated-v4.yml b/.github/workflows/smoketest-dotnet-isolated-v4.yml index 2e5b3842f..f818ff7ae 100644 --- a/.github/workflows/smoketest-dotnet-isolated-v4.yml +++ b/.github/workflows/smoketest-dotnet-isolated-v4.yml @@ -1,6 +1,7 @@ name: Smoke Test - .NET Isolated on Functions V4 on: + workflow_dispatch: push: branches: [ main, dev ] paths: diff --git a/.github/workflows/smoketest-dotnet-v2.yml b/.github/workflows/smoketest-dotnet-v2.yml index 0b4ab0e7b..2dc5c5417 100644 --- a/.github/workflows/smoketest-dotnet-v2.yml +++ b/.github/workflows/smoketest-dotnet-v2.yml @@ -1,6 +1,7 @@ name: Smoke Test - .NET on Functions V2 on: + workflow_dispatch: push: branches: [ main, dev ] paths: diff --git a/.github/workflows/smoketest-dotnet-v3.yml b/.github/workflows/smoketest-dotnet-v3.yml index a7ab2d8e6..4644c5df9 100644 --- a/.github/workflows/smoketest-dotnet-v3.yml +++ b/.github/workflows/smoketest-dotnet-v3.yml @@ -1,6 +1,7 @@ name: Smoke Test - .NET on Functions V3 on: + workflow_dispatch: push: branches: [ main, dev ] paths: diff --git a/.github/workflows/smoketest-java8-v4.yml b/.github/workflows/smoketest-java8-v4.yml index dfec91f79..e118632bb 100644 --- a/.github/workflows/smoketest-java8-v4.yml +++ b/.github/workflows/smoketest-java8-v4.yml @@ -1,6 +1,7 @@ name: Smoke Test - Java 8 on Functions V4 on: + workflow_dispatch: push: branches: [ main, dev ] paths: diff --git a/.github/workflows/smoketest-mssql-inproc-v4.yml b/.github/workflows/smoketest-mssql-inproc-v4.yml index d5bf3c13a..e2b25adab 100644 --- a/.github/workflows/smoketest-mssql-inproc-v4.yml +++ b/.github/workflows/smoketest-mssql-inproc-v4.yml @@ -1,23 +1,26 @@ name: Smoke Test - .NET in-proc w/ MSSQL on Functions V4 on: + workflow_dispatch: push: branches: [ main, dev ] paths: - 'src/**' - 'test/SmokeTests/BackendSmokeTests/MSSQL/**' + - '.github/workflows/smoketest-mssql-inproc-v4.yml' pull_request: branches: [ main, dev ] paths: - 'src/**' - 'test/SmokeTests/BackendSmokeTests/MSSQL/**' + - '.github/workflows/smoketest-mssql-inproc-v4.yml' jobs: build: runs-on: ubuntu-latest env: - SA_PASSWORD: NotASecret!12 + SA_PASSWORD: ${{ secrets.SA_PASSWORD }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/smoketest-netherite-inproc-v4.yml b/.github/workflows/smoketest-netherite-inproc-v4.yml index 607e2ad71..c8d62c1d7 100644 --- a/.github/workflows/smoketest-netherite-inproc-v4.yml +++ b/.github/workflows/smoketest-netherite-inproc-v4.yml @@ -1,6 +1,7 @@ name: Smoke Test - .NET in-proc w/ Netherite on Functions V4 on: + workflow_dispatch: push: branches: [ main, dev ] paths: diff --git a/.github/workflows/smoketest-node14-v4.yml b/.github/workflows/smoketest-node14-v4.yml index 376c67933..3078b8d10 100644 --- a/.github/workflows/smoketest-node14-v4.yml +++ b/.github/workflows/smoketest-node14-v4.yml @@ -1,6 +1,7 @@ name: Smoke Test - Node 14 on Functions V4 on: + workflow_dispatch: push: branches: [ main, dev ] paths: diff --git a/.github/workflows/smoketest-python37-v4.yml b/.github/workflows/smoketest-python37-v4.yml index b5ac000ec..70b7b5378 100644 --- a/.github/workflows/smoketest-python37-v4.yml +++ b/.github/workflows/smoketest-python37-v4.yml @@ -1,6 +1,7 @@ name: Smoke Test - Python 3.7 on Functions V4 on: + workflow_dispatch: push: branches: [ main, dev ] paths: diff --git a/release_notes.md b/release_notes.md index 28441f566..700f290a6 100644 --- a/release_notes.md +++ b/release_notes.md @@ -1,16 +1,14 @@ # Release Notes -## Microsoft.Azure.Functions.Worker.Extensions.DurableTask 1.1.1 +## Microsoft.Azure.Functions.Worker.Extensions.DurableTask 1.2.0 ### New Features -- Add `CreateCheckStatusResponseAsync` APIs. (https://github.com/Azure/azure-functions-durable-extension/pull/2722) +- Add `suspendPostUri` and `resumePostUri` to the list of returned URIs in `CreateCheckStatusResponseAsync`. (https://github.com/Azure/azure-functions-durable-extension/pull/2785) +- Fix `NotSupportedException` when calling `PurgeAllInstancesAsync` and `PurgeInstanceAsync` ### Bug Fixes -- Fix issue with isolated entities: custom deserialization was not working because IServices was not passed along (https://github.com/Azure/azure-functions-durable-extension/pull/2686) -- Fix issue with `string` activity input having extra quotes (https://github.com/Azure/azure-functions-durable-extension/pull/2708) - ### Breaking Changes ### Dependency Updates diff --git a/src/WebJobs.Extensions.DurableTask/Auth/AzureCredentialFactory.cs b/src/WebJobs.Extensions.DurableTask/Auth/AzureCredentialFactory.cs index d296ea5f2..ab46f9f61 100644 --- a/src/WebJobs.Extensions.DurableTask/Auth/AzureCredentialFactory.cs +++ b/src/WebJobs.Extensions.DurableTask/Auth/AzureCredentialFactory.cs @@ -72,6 +72,10 @@ public AppAuthTokenCredential Create(IConfiguration configuration, TimeSpan toke RefreshOffset = tokenRefreshOffset, }; + // The token credential will make background callbacks to renew the token. + // We suppress async flow to avoid logging scope from being captured as we do not know + // where this will be called from first. + using AsyncFlowControl flowControl = System.Threading.ExecutionContext.SuppressFlow(); return new AppAuthTokenCredential( value.Token, (o, t) => this.RenewTokenAsync((TokenRenewalState)o, t), diff --git a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableActivityContext.cs b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableActivityContext.cs index 560fedf48..e0b5b9900 100644 --- a/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableActivityContext.cs +++ b/src/WebJobs.Extensions.DurableTask/ContextImplementations/DurableActivityContext.cs @@ -127,15 +127,18 @@ internal object GetInput(Type destinationType) string serializedValue = jToken.ToString(Formatting.None); - // Object inputs for out-of-proc activities are passed in their JSON-stringified form with a destination - // type of System.String. Unfortunately, deserializing a JSON string to a string causes - // MessagePayloadDataConverter to throw an exception. This is a workaround for that case. All other - // inputs with destination System.String (in-proc: JSON and not JSON; out-of-proc: not-JSON) inputs with - // destination System.String should cast to JValues and be handled above.) - if (this.rawInput) + if (this.rawInput) // the "modern" OOProc protocol case { return serializedValue; } + else if (destinationType.Equals(typeof(string))) // legacy OOProc protocol case (JS, Python, PowerShell) + { + // Object/complex inputs in "legacy" out-of-proc activities are passed with a destination + // type of System.String (to be precise, if inspected with a debugger, you'll see a stringified JSON). + // Unfortunately, deserializing a JSON string to a string causes MessagePayloadDataConverter to throw an exception, so the + // return statement prevents that. + return serializedValue; + } return this.messageDataConverter.Deserialize(serializedValue, destinationType); } diff --git a/src/WebJobs.Extensions.DurableTask/ContextImplementations/RemoteOrchestratorContext.cs b/src/WebJobs.Extensions.DurableTask/ContextImplementations/RemoteOrchestratorContext.cs index 8cc630bf8..5a8a50482 100644 --- a/src/WebJobs.Extensions.DurableTask/ContextImplementations/RemoteOrchestratorContext.cs +++ b/src/WebJobs.Extensions.DurableTask/ContextImplementations/RemoteOrchestratorContext.cs @@ -64,6 +64,20 @@ internal OrchestratorExecutionResult GetResult() return this.executionResult ?? throw new InvalidOperationException($"The execution result has not yet been set using {nameof(this.SetResult)}."); } + internal bool TryGetOrchestrationErrorDetails(out string details) + { + if (this.failure != null) + { + details = this.failure.Message; + return true; + } + else + { + details = string.Empty; + return false; + } + } + internal void SetResult(IEnumerable actions, string customStatus) { var result = new OrchestratorExecutionResult diff --git a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs index 0bc7a9bac..a62b377b1 100644 --- a/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs +++ b/src/WebJobs.Extensions.DurableTask/LocalGrpcListener.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using DurableTask.Core; using DurableTask.Core.Entities; +using DurableTask.Core.Exceptions; using DurableTask.Core.History; using DurableTask.Core.Query; using DurableTask.Core.Serializing.Internal; @@ -54,7 +55,13 @@ public Task StartAsync(CancellationToken cancelToken = default) int numAttempts = 1; while (numAttempts <= maxAttempts) { - this.grpcServer = new Server(); + ChannelOption[] options = new[] + { + new ChannelOption(ChannelOptions.MaxReceiveMessageLength, int.MaxValue), + new ChannelOption(ChannelOptions.MaxSendMessageLength, int.MaxValue), + }; + + this.grpcServer = new Server(options); this.grpcServer.Services.Add(P.TaskHubSidecarService.BindService(new TaskHubGrpcServer(this))); int listeningPort = numAttempts == 1 ? DefaultPort : this.GetRandomPort(); @@ -155,10 +162,23 @@ public override Task Hello(Empty request, ServerCallContext context) InstanceId = instanceId, }; } - catch (InvalidOperationException) + catch (OrchestrationAlreadyExistsException) + { + throw new RpcException(new Status(StatusCode.AlreadyExists, $"An Orchestration instance with the ID {request.InstanceId} already exists.")); + } + catch (InvalidOperationException ex) when (ex.Message.EndsWith("already exists.")) // for older versions of DTF.AS and DTFx.Netherite { throw new RpcException(new Status(StatusCode.AlreadyExists, $"An Orchestration instance with the ID {request.InstanceId} already exists.")); } + catch (Exception ex) + { + this.extension.TraceHelper.ExtensionWarningEvent( + this.extension.Options.HubName, + functionName: request.Name, + instanceId: request.InstanceId, + message: $"Failed to start instanceId {request.InstanceId} due to internal exception.\n Exception trace: {ex}."); + throw new RpcException(new Status(StatusCode.Internal, $"Failed to start instance with ID {request.InstanceId}.\nInner Exception message: {ex.Message}.")); + } } public async override Task RaiseEvent(P.RaiseEventRequest request, ServerCallContext context) diff --git a/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs b/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs index cf45c2521..8a7983cba 100644 --- a/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs +++ b/src/WebJobs.Extensions.DurableTask/OutOfProcMiddleware.cs @@ -214,8 +214,31 @@ await this.LifeCycleNotificationHelper.OrchestratorCompletedAsync( isReplay: false); } } + else if (context.TryGetOrchestrationErrorDetails(out string details)) + { + // the function failed because the orchestrator failed. + + orchestratorResult = context.GetResult(); + + this.TraceHelper.FunctionFailed( + this.Options.HubName, + functionName.Name, + instance.InstanceId, + details, + FunctionType.Orchestrator, + isReplay: false); + + await this.LifeCycleNotificationHelper.OrchestratorFailedAsync( + this.Options.HubName, + functionName.Name, + instance.InstanceId, + details, + isReplay: false); + } else { + // the function failed for some other reason + string exceptionDetails = functionResult.Exception.ToString(); this.TraceHelper.FunctionFailed( @@ -374,15 +397,27 @@ void SetErrorResult(FailureDetails failureDetails) functionName.Name, batchRequest.InstanceId, functionResult.Exception.ToString(), - FunctionType.Orchestrator, + FunctionType.Entity, isReplay: false); - SetErrorResult(new FailureDetails( - errorType: "FunctionInvocationFailed", - errorMessage: $"Invocation of function '{functionName}' failed with an exception.", - stackTrace: null, - innerFailure: new FailureDetails(functionResult.Exception), - isNonRetriable: true)); + if (context.Result != null) + { + // Send the results of the entity batch execution back to the DTFx dispatch pipeline. + // This is important so we can propagate the individual failure details of each failed operation back to the + // calling orchestrator. Also, even though the function execution was reported as a failure, + // it may not be a "total failure", i.e. some of the operations in the batch may have succeeded and updated + // the entity state. + dispatchContext.SetProperty(context.Result); + } + else + { + SetErrorResult(new FailureDetails( + errorType: "FunctionInvocationFailed", + errorMessage: $"Invocation of function '{functionName}' failed with an exception.", + stackTrace: null, + innerFailure: new FailureDetails(functionResult.Exception), + isNonRetriable: true)); + } return; } @@ -399,8 +434,7 @@ void SetErrorResult(FailureDetails failureDetails) FunctionType.Entity, isReplay: false); - // Send the result of the orchestrator function to the DTFx dispatch pipeline. - // This allows us to bypass the default, in-process execution and process the given results immediately. + // Send the results of the entity batch execution back to the DTFx dispatch pipeline. dispatchContext.SetProperty(batchResult); } @@ -621,23 +655,40 @@ private static bool TrySplitExceptionTypeFromMessage( [NotNullWhen(true)] out string? exceptionType, [NotNullWhen(true)] out string? exceptionMessage) { - // Example exception messages: + // In certain situations, like when the .NET Isolated worker is configured with + // WorkerOptions.EnableUserCodeException = true, the exception message we get from the .NET Isolated + // worker looks like this: + // "Exception of type 'ExceptionSerialization.Function+UnknownException' was thrown." + const string startMarker = "Exception of type '"; + const string endMarker = "' was thrown."; + if (exception.StartsWith(startMarker) && exception.EndsWith(endMarker)) + { + exceptionType = exception[startMarker.Length..^endMarker.Length]; + exceptionMessage = string.Empty; + return true; + } + + // The following are the more common cases that we expect to see, which will be common across a + // variety of language workers: // .NET : System.ApplicationException: Kah-BOOOOM!! // Java : SQLServerException: Invalid column name 'status'. // Python : ResourceNotFoundError: The specified blob does not exist. RequestId:8d5a2c9b-b01e-006f-33df-3f7a2e000000 Time:2022-03-25T00:31:24.2003327Z ErrorCode:BlobNotFound Error:None // Node : SyntaxError: Unexpected token N in JSON at position 12768 - // From the above examples, they all follow the same pattern of {ExceptionType}: {Message} + // From the above examples, they all follow the same pattern of {ExceptionType}: {Message}. + // However, some exception types override the ToString() method and do something custom, in which + // case the message may not be in the expected format. In such cases we won't be able to distinguish + // the exception type. string delimeter = ": "; int endExceptionType = exception.IndexOf(delimeter); if (endExceptionType < 0) { exceptionType = null; - exceptionMessage = null; + exceptionMessage = exception; return false; } - exceptionType = exception[..endExceptionType]; + exceptionType = exception[..endExceptionType].TrimEnd(); // The .NET Isolated language worker strangely includes the stack trace in the exception message. // To avoid bloating the payload with redundant information, we only consider the first line. @@ -645,7 +696,7 @@ private static bool TrySplitExceptionTypeFromMessage( int endMessage = exception.IndexOf('\n', startMessage); if (endMessage < 0) { - exceptionMessage = exception[startMessage..]; + exceptionMessage = exception[startMessage..].TrimEnd(); } else { diff --git a/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj b/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj index 2c303b70f..5876cc724 100644 --- a/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj +++ b/src/WebJobs.Extensions.DurableTask/WebJobs.Extensions.DurableTask.csproj @@ -6,7 +6,7 @@ Microsoft.Azure.WebJobs.Extensions.DurableTask 2 13 - 1 + 3 $(PackageSuffix) $(MajorVersion).$(MinorVersion).$(PatchVersion) $(MajorVersion).0.0.0 @@ -75,7 +75,7 @@ $(AssemblyName).xml - + @@ -96,7 +96,7 @@ $(DefineConstants);FUNCTIONS_V2_OR_GREATER;FUNCTIONS_V3_OR_GREATER - + @@ -113,8 +113,8 @@ - - + + diff --git a/src/Worker.Extensions.DurableTask/ActivityInputConverter.cs b/src/Worker.Extensions.DurableTask/ActivityInputConverter.cs index 518b1ded7..192d812ba 100644 --- a/src/Worker.Extensions.DurableTask/ActivityInputConverter.cs +++ b/src/Worker.Extensions.DurableTask/ActivityInputConverter.cs @@ -25,6 +25,13 @@ public ValueTask ConvertAsync(ConverterContext context) throw new ArgumentNullException(nameof(context)); } + // Special handling for FunctionContext + // This addresses cases where the activity function has only FunctionContext as a parameter. + if (context.TargetType == typeof(FunctionContext)) + { + return new(ConversionResult.Unhandled()); + } + if (context.Source is null) { return new(ConversionResult.Success(null)); diff --git a/src/Worker.Extensions.DurableTask/ActivityTriggerAttribute.cs b/src/Worker.Extensions.DurableTask/ActivityTriggerAttribute.cs index f4b944192..c5e60f054 100644 --- a/src/Worker.Extensions.DurableTask/ActivityTriggerAttribute.cs +++ b/src/Worker.Extensions.DurableTask/ActivityTriggerAttribute.cs @@ -15,7 +15,7 @@ namespace Microsoft.Azure.Functions.Worker; [AttributeUsage(AttributeTargets.Parameter)] [DebuggerDisplay("{Activity}")] [InputConverter(typeof(ActivityInputConverter))] -[ConverterFallbackBehavior(ConverterFallbackBehavior.Disallow)] +[ConverterFallbackBehavior(ConverterFallbackBehavior.Allow)] public sealed class ActivityTriggerAttribute : TriggerBindingAttribute { /// diff --git a/src/Worker.Extensions.DurableTask/AssemblyInfo.cs b/src/Worker.Extensions.DurableTask/AssemblyInfo.cs index 281c9f373..32927df11 100644 --- a/src/Worker.Extensions.DurableTask/AssemblyInfo.cs +++ b/src/Worker.Extensions.DurableTask/AssemblyInfo.cs @@ -4,4 +4,4 @@ using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; // TODO: Find a way to generate this dynamically at build-time -[assembly: ExtensionInformation("Microsoft.Azure.WebJobs.Extensions.DurableTask", "2.13.1")] +[assembly: ExtensionInformation("Microsoft.Azure.WebJobs.Extensions.DurableTask", "2.13.3")] diff --git a/src/Worker.Extensions.DurableTask/DurableTaskClientExtensions.cs b/src/Worker.Extensions.DurableTask/DurableTaskClientExtensions.cs index 1a487f388..286c206fe 100644 --- a/src/Worker.Extensions.DurableTask/DurableTaskClientExtensions.cs +++ b/src/Worker.Extensions.DurableTask/DurableTaskClientExtensions.cs @@ -56,6 +56,11 @@ public static async Task CreateCheckStatusResponseAsync( throw new ArgumentNullException(nameof(client)); } + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + HttpResponseData response = request.CreateResponse(statusCode); object payload = SetHeadersAndGetPayload(client, request, response, instanceId); @@ -102,6 +107,11 @@ public static HttpResponseData CreateCheckStatusResponse( throw new ArgumentNullException(nameof(client)); } + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + HttpResponseData response = request.CreateResponse(statusCode); object payload = SetHeadersAndGetPayload(client, request, response, instanceId); @@ -146,7 +156,9 @@ static string BuildUrl(string url, params string?[] queryValues) purgeHistoryDeleteUri = BuildUrl(instanceUrl, commonQueryParameters), sendEventPostUri = BuildUrl($"{instanceUrl}/raiseEvent/{{eventName}}", commonQueryParameters), statusQueryGetUri = BuildUrl(instanceUrl, commonQueryParameters), - terminatePostUri = BuildUrl($"{instanceUrl}/terminate", "reason={{text}}}", commonQueryParameters), + terminatePostUri = BuildUrl($"{instanceUrl}/terminate", "reason={{text}}", commonQueryParameters), + suspendPostUri = BuildUrl($"{instanceUrl}/suspend", "reason={{text}}", commonQueryParameters), + resumePostUri = BuildUrl($"{instanceUrl}/resume", "reason={{text}}", commonQueryParameters) }; } diff --git a/src/Worker.Extensions.DurableTask/FunctionsDurableTaskClient.cs b/src/Worker.Extensions.DurableTask/FunctionsDurableTaskClient.cs index d675f2ab2..d47eef1f5 100644 --- a/src/Worker.Extensions.DurableTask/FunctionsDurableTaskClient.cs +++ b/src/Worker.Extensions.DurableTask/FunctionsDurableTaskClient.cs @@ -46,15 +46,15 @@ public override AsyncPageable GetAllInstancesAsync(Orches } public override Task PurgeAllInstancesAsync( - PurgeInstancesFilter filter, CancellationToken cancellation = default) + PurgeInstancesFilter filter, PurgeInstanceOptions? options = null, CancellationToken cancellation = default) { - return this.inner.PurgeAllInstancesAsync(filter, cancellation); + return this.inner.PurgeAllInstancesAsync(filter, options, cancellation); } public override Task PurgeInstanceAsync( - string instanceId, CancellationToken cancellation = default) + string instanceId, PurgeInstanceOptions? options = null, CancellationToken cancellation = default) { - return this.inner.PurgeInstanceAsync(instanceId, cancellation); + return this.inner.PurgeInstanceAsync(instanceId, options, cancellation); } public override Task RaiseEventAsync( diff --git a/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj b/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj index c445f7121..7a93ae655 100644 --- a/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj +++ b/src/Worker.Extensions.DurableTask/Worker.Extensions.DurableTask.csproj @@ -29,18 +29,18 @@ ..\..\sign.snk - 1.1.0 + 1.1.3 $(VersionPrefix).0 - + $(VersionPrefix).$(FileVersionRevision) - - + + diff --git a/test/SmokeTests/BackendSmokeTests/MSSQL/Dockerfile b/test/SmokeTests/BackendSmokeTests/MSSQL/Dockerfile index 504eb0cd3..8a2dea435 100644 --- a/test/SmokeTests/BackendSmokeTests/MSSQL/Dockerfile +++ b/test/SmokeTests/BackendSmokeTests/MSSQL/Dockerfile @@ -3,7 +3,7 @@ FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build-env -# Build the app +# Build the DF MSSQL app COPY . /root RUN cd /root/test/SmokeTests/BackendSmokeTests/MSSQL && \ mkdir -p /home/site/wwwroot && \ diff --git a/test/SmokeTests/OOProcSmokeTests/durableJS/DurableFunctionsOrchestratorJS/index.js b/test/SmokeTests/OOProcSmokeTests/durableJS/DurableFunctionsOrchestratorJS/index.js index b76067416..088dad5b7 100644 --- a/test/SmokeTests/OOProcSmokeTests/durableJS/DurableFunctionsOrchestratorJS/index.js +++ b/test/SmokeTests/OOProcSmokeTests/durableJS/DurableFunctionsOrchestratorJS/index.js @@ -13,12 +13,15 @@ const df = require("durable-functions"); module.exports = df.orchestrator(function* (context) { const outputs = []; + const city = {city:"Paris", country:"France"}; // Replace "Hello" with the name of your Durable Activity Function. outputs.push(yield context.df.callActivity("Hello", "Tokyo")); outputs.push(yield context.df.callActivity("Hello", "Seattle")); outputs.push(yield context.df.callActivity("Hello", "London")); + outputs.push(yield context.df.callActivity("Hello", 123)); + outputs.push(yield context.df.callActivity("PrintArray", ["Dubai", "New York", "Vancouver"])); + outputs.push(yield context.df.callActivity("PrintObject", city)); - // returns ["Hello Tokyo!", "Hello Seattle!", "Hello London!"] return outputs; -}); \ No newline at end of file +}); diff --git a/test/SmokeTests/OOProcSmokeTests/durableJS/PrintArray/function.json b/test/SmokeTests/OOProcSmokeTests/durableJS/PrintArray/function.json new file mode 100644 index 000000000..e04ea285f --- /dev/null +++ b/test/SmokeTests/OOProcSmokeTests/durableJS/PrintArray/function.json @@ -0,0 +1,9 @@ +{ + "bindings": [ + { + "name": "array", + "type": "activityTrigger", + "direction": "in" + } + ] +} diff --git a/test/SmokeTests/OOProcSmokeTests/durableJS/PrintArray/index.js b/test/SmokeTests/OOProcSmokeTests/durableJS/PrintArray/index.js new file mode 100644 index 000000000..0b35afabe --- /dev/null +++ b/test/SmokeTests/OOProcSmokeTests/durableJS/PrintArray/index.js @@ -0,0 +1,14 @@ +/* + * This function is not intended to be invoked directly. Instead it will be + * triggered by an orchestrator function. + * + * Before running this sample, please: + * - create a Durable orchestration function + * - create a Durable HTTP starter function + * - run 'npm install durable-functions' from the wwwroot folder of your + * function app in Kudu + */ + +module.exports = async function (context) { + return context.bindings.array.toString(); +}; diff --git a/test/SmokeTests/OOProcSmokeTests/durableJS/PrintObject/function.json b/test/SmokeTests/OOProcSmokeTests/durableJS/PrintObject/function.json new file mode 100644 index 000000000..51d541c46 --- /dev/null +++ b/test/SmokeTests/OOProcSmokeTests/durableJS/PrintObject/function.json @@ -0,0 +1,9 @@ +{ + "bindings": [ + { + "name": "cities", + "type": "activityTrigger", + "direction": "in" + } + ] +} diff --git a/test/SmokeTests/OOProcSmokeTests/durableJS/PrintObject/index.js b/test/SmokeTests/OOProcSmokeTests/durableJS/PrintObject/index.js new file mode 100644 index 000000000..f280765cd --- /dev/null +++ b/test/SmokeTests/OOProcSmokeTests/durableJS/PrintObject/index.js @@ -0,0 +1,14 @@ +/* + * This function is not intended to be invoked directly. Instead it will be + * triggered by an orchestrator function. + * + * Before running this sample, please: + * - create a Durable orchestration function + * - create a Durable HTTP starter function + * - run 'npm install durable-functions' from the wwwroot folder of your + * function app in Kudu + */ + +module.exports = async function (context) { + return JSON.stringify(context.bindings.obj); +}; diff --git a/test/SmokeTests/OOProcSmokeTests/durableJava/src/main/java/com/functions/AzureFunctions.java b/test/SmokeTests/OOProcSmokeTests/durableJava/src/main/java/com/functions/AzureFunctions.java index aa8a7078d..e9c92d761 100644 --- a/test/SmokeTests/OOProcSmokeTests/durableJava/src/main/java/com/functions/AzureFunctions.java +++ b/test/SmokeTests/OOProcSmokeTests/durableJava/src/main/java/com/functions/AzureFunctions.java @@ -40,10 +40,18 @@ public String citiesOrchestrator( @DurableOrchestrationTrigger(name = "orchestratorRequestProtoBytes") String orchestratorRequestProtoBytes) { return OrchestrationRunner.loadAndRun(orchestratorRequestProtoBytes, ctx -> { String result = ""; + String[] cities = {"Dubai", "New York", "Vancouver"}; + City paris = new City("France", "Paris"); + result += ctx.callActivity("Capitalize", "Tokyo", String.class).await() + ", "; result += ctx.callActivity("Capitalize", "London", String.class).await() + ", "; result += ctx.callActivity("Capitalize", "Seattle", String.class).await() + ", "; - result += ctx.callActivity("Capitalize", "Austin", String.class).await(); + result += ctx.callActivity("Capitalize", "Austin", String.class).await()+ ", "; + + result += ctx.callActivity("Print", 123, String.class).await()+ ", "; + result += ctx.callActivity("PrintArray", cities, String.class).await()+ ", "; + result += ctx.callActivity("PrintObject", paris, String.class).await()+ ", "; + return result; }); } @@ -58,4 +66,59 @@ public String capitalize( context.getLogger().info("Capitalizing: " + name); return name.toUpperCase(); } + + @FunctionName("Print") + public String print( + @DurableActivityTrigger(name = "input") String input, + final ExecutionContext context) { + context.getLogger().info("Printing input: " + input); + return input.toString(); + } + + @FunctionName("PrintArray") + public String printArray( + @DurableActivityTrigger(name = "array") String[] array, + final ExecutionContext context) { + context.getLogger().info(Arrays.toString(array)); + return Arrays.toString(array); + } + + @FunctionName("PrintObject") + public String printObject( + @DurableActivityTrigger(name = "city") City city, + final ExecutionContext context) { + context.getLogger().info("Printing object" + city.toString()); + return city.toString(); + } + + public class City { + private String country; + private String name; + + public City(String country, String name){ + this.country = country; + this.name = name; + } + + public String getCountry() { + return country; + } + + public void setCountry(String country) { + this.country = country; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String toString() { + return "City [Country=" + country + ", name=" + name + "]"; + } + } } diff --git a/test/SmokeTests/OOProcSmokeTests/durablePy/DurableFunctionsOrchestrator/__init__.py b/test/SmokeTests/OOProcSmokeTests/durablePy/DurableFunctionsOrchestrator/__init__.py index 3cc2e3e03..313e30766 100644 --- a/test/SmokeTests/OOProcSmokeTests/durablePy/DurableFunctionsOrchestrator/__init__.py +++ b/test/SmokeTests/OOProcSmokeTests/durablePy/DurableFunctionsOrchestrator/__init__.py @@ -12,11 +12,34 @@ import azure.functions as func import azure.durable_functions as df +class City: + def __init__(self, country, name): + self.country = country + self.name = name + + def to_json(self): + return json.dumps({"name": self.name, "country": self.country}) + + @classmethod + def from_json(cls, json_str): + data = json.loads(json_str) + return cls(name=data['name'], country=data['country']) + + def __str__(self): + return f"City(name= {self.name}, country= {self.country})" def orchestrator_function(context: df.DurableOrchestrationContext): result1 = yield context.call_activity('Hello', "Tokyo") result2 = yield context.call_activity('Hello', "Seattle") result3 = yield context.call_activity('Hello', "London") - return [result1, result2, result3] + result4 = yield context.call_activity('Print', 123) + + cities = ["Tokyo", "Seattle", "Cairo"] + result5 = yield context.call_activity("PrintArray", cities) + + city = City("France", "Paris") + result6 = yield context.call_activity("PrintObject", city) + + return [result1, result2, result3, result4, result5, result6] main = df.Orchestrator.create(orchestrator_function) \ No newline at end of file diff --git a/test/SmokeTests/OOProcSmokeTests/durablePy/Print/__init__.py b/test/SmokeTests/OOProcSmokeTests/durablePy/Print/__init__.py new file mode 100644 index 000000000..71c39e6a9 --- /dev/null +++ b/test/SmokeTests/OOProcSmokeTests/durablePy/Print/__init__.py @@ -0,0 +1,13 @@ +# This function is not intended to be invoked directly. Instead it will be +# triggered by an orchestrator function. +# Before running this sample, please: +# - create a Durable orchestration function +# - create a Durable HTTP starter function +# - add azure-functions-durable to requirements.txt +# - run pip install -r requirements.txt + +import logging + + +def main(input: int) -> str: + return f"{input}" diff --git a/test/SmokeTests/OOProcSmokeTests/durablePy/Print/function.json b/test/SmokeTests/OOProcSmokeTests/durablePy/Print/function.json new file mode 100644 index 000000000..fdc7a42b0 --- /dev/null +++ b/test/SmokeTests/OOProcSmokeTests/durablePy/Print/function.json @@ -0,0 +1,10 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "name": "input", + "type": "activityTrigger", + "direction": "in" + } + ] +} \ No newline at end of file diff --git a/test/SmokeTests/OOProcSmokeTests/durablePy/PrintArray/__init__.py b/test/SmokeTests/OOProcSmokeTests/durablePy/PrintArray/__init__.py new file mode 100644 index 000000000..2f3252058 --- /dev/null +++ b/test/SmokeTests/OOProcSmokeTests/durablePy/PrintArray/__init__.py @@ -0,0 +1,16 @@ +# This function is not intended to be invoked directly. Instead it will be +# triggered by an orchestrator function. +# Before running this sample, please: +# - create a Durable orchestration function +# - create a Durable HTTP starter function +# - add azure-functions-durable to requirements.txt +# - run pip install -r requirements.txt + +import logging + +def main(cities: list) -> str: + results = ""; + for city in cities: + result = f"{city} " + results += result + return results diff --git a/test/SmokeTests/OOProcSmokeTests/durablePy/PrintArray/function.json b/test/SmokeTests/OOProcSmokeTests/durablePy/PrintArray/function.json new file mode 100644 index 000000000..f1af9c9bf --- /dev/null +++ b/test/SmokeTests/OOProcSmokeTests/durablePy/PrintArray/function.json @@ -0,0 +1,10 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "name": "cities", + "type": "activityTrigger", + "direction": "in" + } + ] +} \ No newline at end of file diff --git a/test/SmokeTests/OOProcSmokeTests/durablePy/PrintObject/__init__.py b/test/SmokeTests/OOProcSmokeTests/durablePy/PrintObject/__init__.py new file mode 100644 index 000000000..3974e957d --- /dev/null +++ b/test/SmokeTests/OOProcSmokeTests/durablePy/PrintObject/__init__.py @@ -0,0 +1,13 @@ +# This function is not intended to be invoked directly. Instead it will be +# triggered by an orchestrator function. +# Before running this sample, please: +# - create a Durable orchestration function +# - create a Durable HTTP starter function +# - add azure-functions-durable to requirements.txt +# - run pip install -r requirements.txt + +import logging + + +def main(obj: object) -> str: + return f"Printing object: {obj}" diff --git a/test/SmokeTests/OOProcSmokeTests/durablePy/PrintObject/function.json b/test/SmokeTests/OOProcSmokeTests/durablePy/PrintObject/function.json new file mode 100644 index 000000000..962f775c5 --- /dev/null +++ b/test/SmokeTests/OOProcSmokeTests/durablePy/PrintObject/function.json @@ -0,0 +1,10 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "name": "obj", + "type": "activityTrigger", + "direction": "in" + } + ] +} \ No newline at end of file diff --git a/test/SmokeTests/e2e-test.ps1 b/test/SmokeTests/e2e-test.ps1 index f271507c1..845c35eb2 100644 --- a/test/SmokeTests/e2e-test.ps1 +++ b/test/SmokeTests/e2e-test.ps1 @@ -1,5 +1,4 @@ -# Installing PowerShell: https://docs.microsoft.com/powershell/scripting/install/installing-powershell - + param( [Parameter(Mandatory=$true)] [string]$DockerfilePath, @@ -10,7 +9,6 @@ param( [switch]$NoSetup=$false, [switch]$NoValidation=$false, [int]$Sleep=30, - [string]$additinalRunFlags="", [switch]$SetupSQLServer=$false, [string]$pw="$env:SA_PASSWORD", [string]$sqlpid="Express", @@ -20,6 +18,13 @@ param( [string]$collation="Latin1_General_100_BIN2_UTF8" ) +function Exit-OnError() { + # There appears to be a known problem in GitHub Action's `pwsh` shell preventing it from failing fast on an error: + # https://github.com/actions/runner-images/issues/6668#issuecomment-1364540817 + # Therefore, we manually check if there was an error an fail if so. + if (!$LASTEXITCODE.Equals(0)) {exit $LASTEXITCODE} +} + $ErrorActionPreference = "Stop" $AzuriteVersion = "3.26.0" @@ -27,37 +32,46 @@ if ($NoSetup -eq $false) { # Build the docker image first, since that's the most critical step Write-Host "Building sample app Docker container from '$DockerfilePath'..." -ForegroundColor Yellow docker build -f $DockerfilePath -t $ImageName --progress plain $PSScriptRoot/../../ + Exit-OnError # Next, download and start the Azurite emulator Docker image Write-Host "Pulling down the mcr.microsoft.com/azure-storage/azurite:$AzuriteVersion image..." -ForegroundColor Yellow docker pull "mcr.microsoft.com/azure-storage/azurite:${AzuriteVersion}" + Exit-OnError Write-Host "Starting Azurite storage emulator using default ports..." -ForegroundColor Yellow docker run --name 'azurite' -p 10000:10000 -p 10001:10001 -p 10002:10002 -d "mcr.microsoft.com/azure-storage/azurite:${AzuriteVersion}" + Exit-OnError if ($SetupSQLServer -eq $true) { Write-Host "Pulling down the mcr.microsoft.com/mssql/server:$tag image..." docker pull mcr.microsoft.com/mssql/server:$tag - + Exit-OnError + # Start the SQL Server docker container with the specified edition Write-Host "Starting SQL Server $tag $sqlpid docker container on port $port" -ForegroundColor DarkYellow - docker run $additinalRunFlags --name mssql-server -e 'ACCEPT_EULA=Y' -e "MSSQL_SA_PASSWORD=$pw" -e "MSSQL_PID=$sqlpid" -p ${port}:1433 -d mcr.microsoft.com/mssql/server:$tag - + docker run --name mssql-server -e 'ACCEPT_EULA=Y' -e "MSSQL_SA_PASSWORD=$pw" -e "MSSQL_PID=$sqlpid" -p ${port}:1433 -d mcr.microsoft.com/mssql/server:$tag + Exit-OnError + # Wait for SQL Server to be ready Write-Host "Waiting for SQL Server to be ready..." -ForegroundColor Yellow Start-Sleep -Seconds 30 # Adjust the sleep duration based on your SQL Server container startup time + Exit-OnError # Get SQL Server IP Address - used to create SQLDB_Connection Write-Host "Getting IP Address..." -ForegroundColor Yellow $serverIpAddress = docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' mssql-server - + Exit-OnError + # Create the database with strict binary collation Write-Host "Creating '$dbname' database with '$collation' collation" -ForegroundColor DarkYellow docker exec -d mssql-server /opt/mssql-tools/bin/sqlcmd -S . -U sa -P "$pw" -Q "CREATE DATABASE [$dbname] COLLATE $collation" + Exit-OnError # Wait for database to be ready Write-Host "Waiting for database to be ready..." -ForegroundColor Yellow Start-Sleep -Seconds 30 # Adjust the sleep duration based on your database container startup time + Exit-OnError # Finally, start up the application container, connecting to the SQL Server container Write-Host "Starting the $ContainerName application container" -ForegroundColor Yellow @@ -66,6 +80,7 @@ if ($NoSetup -eq $false) { --env 'AzureWebJobsStorage=UseDevelopmentStorage=true;DevelopmentStorageProxyUri=http://host.docker.internal' ` --env 'WEBSITE_HOSTNAME=localhost:8080' ` $ImageName + Exit-OnError } else { Write-Host "Starting $ContainerName application container" -ForegroundColor Yellow @@ -74,6 +89,7 @@ if ($NoSetup -eq $false) { --env 'WEBSITE_HOSTNAME=localhost:8080' ` $ImageName } + Exit-OnError } if ($sleep -gt 0) { @@ -84,12 +100,14 @@ if ($sleep -gt 0) { # Check to see what containers are running docker ps +Exit-OnError try { # Make sure the Functions runtime is up and running $pingUrl = "http://localhost:8080/admin/host/ping" Write-Host "Pinging app at $pingUrl to ensure the host is healthy" -ForegroundColor Yellow Invoke-RestMethod -Method Post -Uri "http://localhost:8080/admin/host/ping" + Exit-OnError if ($NoValidation -eq $false) { # Note that any HTTP protocol errors (e.g. HTTP 4xx or 5xx) will cause an immediate failure @@ -134,4 +152,4 @@ try { throw } -Write-Host "Success!" -ForegroundColor Green +Write-Host "Success!" -ForegroundColor Green \ No newline at end of file