From 317e6e6cafdd01318f3ebb245b97c102405997c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 18:39:18 +0000 Subject: [PATCH 1/5] Initial plan From 4f8cf55d61cf2653573d218e8aea1b2a10a5f090 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 18:51:45 +0000 Subject: [PATCH 2/5] Add GetInput analyzer with Info severity and comprehensive tests Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com> --- src/Analyzers/AnalyzerReleases.Unshipped.md | 1 + .../GetInputOrchestrationAnalyzer.cs | 73 ++++++++ src/Analyzers/Resources.resx | 6 + .../GetInputOrchestrationAnalyzerTests.cs | 175 ++++++++++++++++++ 4 files changed, 255 insertions(+) create mode 100644 src/Analyzers/Orchestration/GetInputOrchestrationAnalyzer.cs create mode 100644 test/Analyzers.Tests/Orchestration/GetInputOrchestrationAnalyzerTests.cs diff --git a/src/Analyzers/AnalyzerReleases.Unshipped.md b/src/Analyzers/AnalyzerReleases.Unshipped.md index 150fb7356..54c608f8b 100644 --- a/src/Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Analyzers/AnalyzerReleases.Unshipped.md @@ -5,5 +5,6 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- +DURABLE0009 | Orchestration | Info | **GetInputOrchestrationAnalyzer**: Suggests using input parameter binding instead of ctx.GetInput() in orchestration methods. DURABLE2003 | Activity | Warning | **FunctionNotFoundAnalyzer**: Warns when an activity function call references a name that does not match any defined activity in the compilation. DURABLE2004 | Orchestration | Warning | **FunctionNotFoundAnalyzer**: Warns when a sub-orchestration call references a name that does not match any defined orchestrator in the compilation. \ No newline at end of file diff --git a/src/Analyzers/Orchestration/GetInputOrchestrationAnalyzer.cs b/src/Analyzers/Orchestration/GetInputOrchestrationAnalyzer.cs new file mode 100644 index 000000000..807946520 --- /dev/null +++ b/src/Analyzers/Orchestration/GetInputOrchestrationAnalyzer.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; +using static Microsoft.DurableTask.Analyzers.Orchestration.GetInputOrchestrationAnalyzer; + +namespace Microsoft.DurableTask.Analyzers.Orchestration; + +/// +/// Analyzer that reports an informational diagnostic when ctx.GetInput() is used in an orchestration method, +/// suggesting the use of input parameter binding instead. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class GetInputOrchestrationAnalyzer : OrchestrationAnalyzer +{ + /// + /// Diagnostic ID supported for the analyzer. + /// + public const string DiagnosticId = "DURABLE0009"; + + static readonly LocalizableString Title = new LocalizableResourceString(nameof(Resources.GetInputOrchestrationAnalyzerTitle), Resources.ResourceManager, typeof(Resources)); + static readonly LocalizableString MessageFormat = new LocalizableResourceString(nameof(Resources.GetInputOrchestrationAnalyzerMessageFormat), Resources.ResourceManager, typeof(Resources)); + + static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + Title, + MessageFormat, + AnalyzersCategories.Orchestration, + DiagnosticSeverity.Info, + isEnabledByDefault: true); + + /// + public override ImmutableArray SupportedDiagnostics => [Rule]; + + /// + /// Visitor that inspects the method body for GetInput calls. + /// + public sealed class GetInputOrchestrationVisitor : MethodProbeOrchestrationVisitor + { + /// + protected override void VisitMethod(SemanticModel semanticModel, SyntaxNode methodSyntax, IMethodSymbol methodSymbol, string orchestrationName, Action reportDiagnostic) + { + IOperation? methodOperation = semanticModel.GetOperation(methodSyntax); + if (methodOperation is null) + { + return; + } + + foreach (IInvocationOperation operation in methodOperation.Descendants().OfType()) + { + IMethodSymbol method = operation.TargetMethod; + + // Check if this is a call to GetInput() on TaskOrchestrationContext + if (method.Name != "GetInput" || !method.IsGenericMethod) + { + continue; + } + + // Verify the containing type is TaskOrchestrationContext + if (!method.ContainingType.Equals(this.KnownTypeSymbols.TaskOrchestrationContext, SymbolEqualityComparer.Default)) + { + continue; + } + + // e.g.: "Consider using an input parameter instead of 'GetInput()' in orchestration 'MyOrchestrator'" + reportDiagnostic(RoslynExtensions.BuildDiagnostic(Rule, operation.Syntax, orchestrationName)); + } + } + } +} diff --git a/src/Analyzers/Resources.resx b/src/Analyzers/Resources.resx index ee14969a3..6b56987f4 100644 --- a/src/Analyzers/Resources.resx +++ b/src/Analyzers/Resources.resx @@ -210,4 +210,10 @@ Sub-orchestration not found + + Consider using an input parameter instead of 'GetInput<T>()' in orchestration '{0}' + + + Input parameter binding can be used instead of GetInput + \ No newline at end of file diff --git a/test/Analyzers.Tests/Orchestration/GetInputOrchestrationAnalyzerTests.cs b/test/Analyzers.Tests/Orchestration/GetInputOrchestrationAnalyzerTests.cs new file mode 100644 index 000000000..5dce0cfb9 --- /dev/null +++ b/test/Analyzers.Tests/Orchestration/GetInputOrchestrationAnalyzerTests.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.CodeAnalysis.Testing; +using Microsoft.DurableTask.Analyzers.Orchestration; + +using VerifyCS = Microsoft.DurableTask.Analyzers.Tests.Verifiers.CSharpCodeFixVerifier; + +namespace Microsoft.DurableTask.Analyzers.Tests.Orchestration; + +public class GetInputOrchestrationAnalyzerTests +{ + [Fact] + public async Task EmptyCodeWithNoSymbolsAvailableHasNoDiag() + { + string code = @""; + + // checks that empty code with no assembly references of Durable Functions has no diagnostics. + // this guarantees that if someone adds our analyzer to a project that doesn't use Durable Functions, + // the analyzer won't crash/they won't get any diagnostics + await VerifyCS.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task EmptyCodeWithSymbolsAvailableHasNoDiag() + { + string code = @""; + + // checks that empty code with access to assembly references of Durable Functions has no diagnostics + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task NonOrchestrationHasNoDiag() + { + string code = Wrapper.WrapDurableFunctionOrchestration(@" +void Method(){ + // This is not an orchestration method, so no diagnostic +} +"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task DurableFunctionOrchestrationUsingGetInputHasInfoDiag() + { + string code = Wrapper.WrapDurableFunctionOrchestration(@" +[Function(""Run"")] +int Run([OrchestrationTrigger] TaskOrchestrationContext context) +{ + int input = {|#0:context.GetInput()|}; + return input; +} +"); + + DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("Run"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task DurableFunctionOrchestrationWithInputParameterHasNoDiag() + { + string code = Wrapper.WrapDurableFunctionOrchestration(@" +[Function(""Run"")] +int Run([OrchestrationTrigger] TaskOrchestrationContext context, int input) +{ + // Using input parameter is the recommended approach + return input; +} +"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task TaskOrchestratorWithInputParameterHasNoDiag() + { + string code = Wrapper.WrapTaskOrchestrator(@" +public class MyOrchestrator : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) + { + // Using input parameter is the recommended approach + return Task.FromResult(input); + } +} +"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task TaskOrchestratorUsingGetInputHasInfoDiag() + { + string code = Wrapper.WrapTaskOrchestrator(@" +public class MyOrchestrator : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, int input) + { + // Even though input parameter exists, GetInput is still flagged as not recommended + int value = {|#0:context.GetInput()|}; + return Task.FromResult(value); + } +} +"); + + DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("MyOrchestrator"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task OrchestratorFuncUsingGetInputHasInfoDiag() + { + string code = Wrapper.WrapFuncOrchestrator(@" +tasks.AddOrchestratorFunc(""MyOrchestration"", (TaskOrchestrationContext context) => +{ + int input = {|#0:context.GetInput()|}; + return Task.FromResult(input); +}); +"); + + DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("MyOrchestration"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task NestedMethodCallWithGetInputHasInfoDiag() + { + string code = Wrapper.WrapDurableFunctionOrchestration(@" +[Function(""Run"")] +int Run([OrchestrationTrigger] TaskOrchestrationContext context) +{ + return HelperMethod(context); +} + +int HelperMethod(TaskOrchestrationContext context) +{ + int input = {|#0:context.GetInput()|}; + return input; +} +"); + + DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("Run"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task MultipleGetInputCallsHaveMultipleDiags() + { + string code = Wrapper.WrapDurableFunctionOrchestration(@" +[Function(""Run"")] +int Run([OrchestrationTrigger] TaskOrchestrationContext context) +{ + int input1 = {|#0:context.GetInput()|}; + int input2 = {|#1:context.GetInput()|}; + return input1 + input2; +} +"); + + DiagnosticResult expected1 = BuildDiagnostic().WithLocation(0).WithArguments("Run"); + DiagnosticResult expected2 = BuildDiagnostic().WithLocation(1).WithArguments("Run"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected1, expected2); + } + + static DiagnosticResult BuildDiagnostic() + { + return VerifyCS.Diagnostic(GetInputOrchestrationAnalyzer.DiagnosticId); + } +} From d07acf011afd9f688b4af2d943d12e15e752bf3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 18:58:49 +0000 Subject: [PATCH 3/5] Add null check for TargetMethod per code review feedback Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com> --- .../Orchestration/GetInputOrchestrationAnalyzer.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Analyzers/Orchestration/GetInputOrchestrationAnalyzer.cs b/src/Analyzers/Orchestration/GetInputOrchestrationAnalyzer.cs index 807946520..8e18d504c 100644 --- a/src/Analyzers/Orchestration/GetInputOrchestrationAnalyzer.cs +++ b/src/Analyzers/Orchestration/GetInputOrchestrationAnalyzer.cs @@ -51,7 +51,11 @@ protected override void VisitMethod(SemanticModel semanticModel, SyntaxNode meth foreach (IInvocationOperation operation in methodOperation.Descendants().OfType()) { - IMethodSymbol method = operation.TargetMethod; + IMethodSymbol? method = operation.TargetMethod; + if (method == null) + { + continue; + } // Check if this is a call to GetInput() on TaskOrchestrationContext if (method.Name != "GetInput" || !method.IsGenericMethod) From 5dfd2dc22e2ef67db340c1f2abae477a786c4cee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 19:04:33 +0000 Subject: [PATCH 4/5] Use CSharpAnalyzerVerifier instead of CodeFixVerifier for consistency Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com> --- .../Orchestration/GetInputOrchestrationAnalyzerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Analyzers.Tests/Orchestration/GetInputOrchestrationAnalyzerTests.cs b/test/Analyzers.Tests/Orchestration/GetInputOrchestrationAnalyzerTests.cs index 5dce0cfb9..ea2b3d3a8 100644 --- a/test/Analyzers.Tests/Orchestration/GetInputOrchestrationAnalyzerTests.cs +++ b/test/Analyzers.Tests/Orchestration/GetInputOrchestrationAnalyzerTests.cs @@ -4,7 +4,7 @@ using Microsoft.CodeAnalysis.Testing; using Microsoft.DurableTask.Analyzers.Orchestration; -using VerifyCS = Microsoft.DurableTask.Analyzers.Tests.Verifiers.CSharpCodeFixVerifier; +using VerifyCS = Microsoft.DurableTask.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier; namespace Microsoft.DurableTask.Analyzers.Tests.Orchestration; From e5ccff126579cf7f4b4d824d7a031d4d72834ea7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 18 Dec 2025 00:35:18 +0000 Subject: [PATCH 5/5] Merge main branch and resolve conflict in AnalyzerReleases.Unshipped.md Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com> --- .github/PULL_REQUEST_TEMPLATE.md | 61 +++ .../workflows/azure-functions-smoke-tests.yml | 45 ++ CHANGELOG.md | 12 +- Directory.Packages.props | 8 +- Microsoft.DurableTask.sln | 7 + eng/targets/Release.props | 2 +- global.json | 2 +- .../AzureFunctionsApp.csproj | 1 + samples/ConsoleApp/ConsoleApp.csproj | 1 + .../ConsoleAppMinimal.csproj | 1 + .../CustomExceptionPropertiesProvider.cs | 45 ++ .../CustomExceptions.cs | 38 ++ .../ExceptionPropertiesSample.csproj | 28 ++ samples/ExceptionPropertiesSample/Program.cs | 154 ++++++ samples/ExceptionPropertiesSample/README.md | 85 ++++ samples/ExceptionPropertiesSample/Tasks.cs | 64 +++ .../LargePayloadConsoleApp.csproj | 3 +- .../NetFxConsoleApp/NetFxConsoleApp.csproj | 1 + .../Orchestrators/StockPriceOrchestrator.cs | 2 +- .../ScheduleConsoleApp.csproj | 1 + samples/ScheduleWebApp/ScheduleWebApp.csproj | 1 + src/Abstractions/TaskOptions.cs | 8 + .../Activities/FunctionNotFoundAnalyzer.cs | 6 +- ...MatchingInputOutputTypeActivityAnalyzer.cs | 6 +- src/Analyzers/AnalyzerReleases.Shipped.md | 9 + src/Analyzers/AnalyzerReleases.Unshipped.md | 2 - src/Analyzers/Analyzers.csproj | 2 +- .../DurableClientBindingAnalyzer.cs | 3 +- .../EntityTriggerBindingAnalyzer.cs | 3 +- .../OrchestrationTriggerBindingAnalyzer.cs | 3 +- .../CancellationTokenOrchestrationAnalyzer.cs | 3 +- .../OtherBindingsOrchestrationAnalyzer.cs | 3 +- .../DateTimeOrchestrationAnalyzer.cs | 28 +- .../DateTimeOrchestrationFixer.cs | 39 +- .../DelayOrchestrationAnalyzer.cs | 3 +- .../EnvironmentOrchestrationAnalyzer.cs | 3 +- .../GuidOrchestrationAnalyzer.cs | 3 +- .../Orchestration/IOOrchestrationAnalyzer.cs | 3 +- .../Orchestration/OrchestrationAnalyzer.cs | 204 ++++---- .../ThreadTaskOrchestrationAnalyzer.cs | 3 +- .../StartOrchestrationOptionsExtensions.cs | 36 ++ src/Client/Grpc/GrpcDurableTaskClient.cs | 37 +- src/Client/Grpc/ProtoUtils.cs | 80 ++- .../ShimDurableTaskClient.cs | 21 +- .../PayloadStore/BlobPayloadStore.cs | 34 +- src/Grpc/orchestrator_service.proto | 8 + src/Grpc/versions.txt | 4 +- .../Sidecar/Grpc/ProtobufUtils.cs | 2 +- .../Sidecar/Grpc/TaskHubGrpcServer.cs | 26 +- .../Sidecar/InMemoryOrchestrationService.cs | 14 +- src/Shared/Grpc/ProtoUtils.cs | 11 +- .../Shims/TaskOrchestrationContextWrapper.cs | 6 +- src/Worker/Grpc/Worker.Grpc.csproj | 1 + .../Abstractions.Tests.csproj | 1 + test/Abstractions.Tests/TaskOptionsTests.cs | 144 +++++- .../DiagnosticDescriptorTests.cs | 50 ++ .../DateTimeOrchestrationAnalyzerTests.cs | 128 +++++ .../CSharpAnalyzerVerifier.Durable.cs | 21 +- .../CSharpCodeFixVerifier.Durable.cs | 60 ++- test/Analyzers.Tests/Verifiers/References.cs | 19 +- test/Analyzers.Tests/Wrapper.cs | 57 ++- .../AzureFunctionsSmokeTests.csproj | 46 ++ test/AzureFunctionsSmokeTests/Dockerfile | 10 + .../HelloCitiesOrchestration.cs | 62 +++ test/AzureFunctionsSmokeTests/Program.cs | 18 + test/AzureFunctionsSmokeTests/README.md | 83 ++++ test/AzureFunctionsSmokeTests/host.json | 21 + .../local.settings.json | 7 + .../run-smoketests.ps1 | 271 ++++++++++ test/Benchmarks/Benchmarks.csproj | 1 + .../Grpc.Tests/GrpcDurableTaskClientTests.cs | 131 +++++ test/Client/Grpc.Tests/ProtoUtilsTests.cs | 463 ++++++++++++++++++ .../ShimDurableTaskClientTests.cs | 205 +++++++- test/Generators.Tests/Generators.Tests.csproj | 1 + .../GrpcDurableTaskClientIntegrationTests.cs | 373 ++++++++++++++ .../OrchestrationPatterns.cs | 48 +- 76 files changed, 3200 insertions(+), 196 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/azure-functions-smoke-tests.yml create mode 100644 samples/ExceptionPropertiesSample/CustomExceptionPropertiesProvider.cs create mode 100644 samples/ExceptionPropertiesSample/CustomExceptions.cs create mode 100644 samples/ExceptionPropertiesSample/ExceptionPropertiesSample.csproj create mode 100644 samples/ExceptionPropertiesSample/Program.cs create mode 100644 samples/ExceptionPropertiesSample/README.md create mode 100644 samples/ExceptionPropertiesSample/Tasks.cs create mode 100644 src/Client/Core/StartOrchestrationOptionsExtensions.cs create mode 100644 test/Analyzers.Tests/DiagnosticDescriptorTests.cs create mode 100644 test/AzureFunctionsSmokeTests/AzureFunctionsSmokeTests.csproj create mode 100644 test/AzureFunctionsSmokeTests/Dockerfile create mode 100644 test/AzureFunctionsSmokeTests/HelloCitiesOrchestration.cs create mode 100644 test/AzureFunctionsSmokeTests/Program.cs create mode 100644 test/AzureFunctionsSmokeTests/README.md create mode 100644 test/AzureFunctionsSmokeTests/host.json create mode 100644 test/AzureFunctionsSmokeTests/local.settings.json create mode 100644 test/AzureFunctionsSmokeTests/run-smoketests.ps1 create mode 100644 test/Client/Grpc.Tests/GrpcDurableTaskClientTests.cs create mode 100644 test/Client/Grpc.Tests/ProtoUtilsTests.cs diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..2b1f0e631 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,61 @@ +# Summary +## What changed? +- + +## Why is this change needed? +- + +## Issues / work items +- Resolves # +- Related # + +--- + +# Project checklist +- [ ] Release notes are not required for the next release + - [ ] Otherwise: Notes added to `release_notes.md` +- [ ] Backport is not required + - [ ] Otherwise: Backport tracked by issue/PR #issue_or_pr +- [ ] All required tests have been added/updated (unit tests, E2E tests) +- [ ] Breaking change? + - [ ] If yes: + - Impact: + - Migration guidance: +--- + +# AI-assisted code disclosure (required) +## Was an AI tool used? (select one) +- [ ] No +- [ ] Yes, AI helped write parts of this PR (e.g., GitHub Copilot) +- [ ] Yes, an AI agent generated most of this PR + +If AI was used: +- Tool(s): +- AI-assisted areas/files: +- What you changed after AI output: + +AI verification (required if AI was used): +- [ ] I understand the code and can explain it +- [ ] I verified referenced APIs/types exist and are correct +- [ ] I reviewed edge cases/failure paths (timeouts, retries, cancellation, exceptions) +- [ ] I reviewed concurrency/async behavior +- [ ] I checked for unintended breaking or behavior changes + +--- + +# Testing +## Automated tests +- Result: Passed / Failed (link logs if failed) + +## Manual validation (only if runtime/behavior changed) +- Environment (OS, .NET version, components): +- Steps + observed results: + 1. + 2. + 3. +- Evidence (optional): + +--- + +# Notes for reviewers +- N/A diff --git a/.github/workflows/azure-functions-smoke-tests.yml b/.github/workflows/azure-functions-smoke-tests.yml new file mode 100644 index 000000000..9ee868fda --- /dev/null +++ b/.github/workflows/azure-functions-smoke-tests.yml @@ -0,0 +1,45 @@ +name: Azure Functions Smoke Tests + +on: + push: + branches: + - main + - 'feature/**' + paths-ignore: [ '**.md' ] + pull_request: + paths-ignore: [ '**.md' ] + workflow_dispatch: + +jobs: + smoke-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '8.0.x' + + - name: Setup .NET from global.json + uses: actions/setup-dotnet@v3 + with: + global-json-file: global.json + + - name: Restore dependencies + run: dotnet restore test/AzureFunctionsSmokeTests/AzureFunctionsSmokeTests.csproj + + - name: Run smoke tests + run: | + cd test/AzureFunctionsSmokeTests + pwsh -File run-smoketests.ps1 + + - name: Upload smoke test logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: smoke-test-logs + path: test/AzureFunctionsSmokeTests/logs/ + if-no-files-found: ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 837ce05fa..160ee29fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,14 @@ -# Changelog +# Changelog + +## 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)) diff --git a/Directory.Packages.props b/Directory.Packages.props index b36897e3f..8445e28e1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,6 +25,8 @@ + + @@ -40,7 +42,7 @@ - + @@ -63,12 +65,12 @@ - + - + diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index 0ca1cad17..837a0ba0c 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -93,6 +93,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScheduledTasks.Tests", "tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LargePayloadConsoleApp", "samples\LargePayloadConsoleApp\LargePayloadConsoleApp.csproj", "{6EB9D002-62C8-D6C1-62A8-14C54CA6DBBC}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExceptionPropertiesSample", "samples\ExceptionPropertiesSample\ExceptionPropertiesSample.csproj", "{7C3ECBCE-BEFB-4982-842E-B654BB6B6285}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AzureBlobPayloads", "src\Extensions\AzureBlobPayloads\AzureBlobPayloads.csproj", "{FE1DA748-D6DB-E168-BC42-6DBBCEAF229C}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InProcessTestHost", "src\InProcessTestHost\InProcessTestHost.csproj", "{5F1E1662-D2D1-4325-BFE3-6AE23A8A4D7E}" @@ -261,6 +263,10 @@ Global {6EB9D002-62C8-D6C1-62A8-14C54CA6DBBC}.Debug|Any CPU.Build.0 = Debug|Any CPU {6EB9D002-62C8-D6C1-62A8-14C54CA6DBBC}.Release|Any CPU.ActiveCfg = Release|Any CPU {6EB9D002-62C8-D6C1-62A8-14C54CA6DBBC}.Release|Any CPU.Build.0 = Release|Any CPU + {7C3ECBCE-BEFB-4982-842E-B654BB6B6285}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C3ECBCE-BEFB-4982-842E-B654BB6B6285}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C3ECBCE-BEFB-4982-842E-B654BB6B6285}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C3ECBCE-BEFB-4982-842E-B654BB6B6285}.Release|Any CPU.Build.0 = Release|Any CPU {FE1DA748-D6DB-E168-BC42-6DBBCEAF229C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FE1DA748-D6DB-E168-BC42-6DBBCEAF229C}.Debug|Any CPU.Build.0 = Debug|Any CPU {FE1DA748-D6DB-E168-BC42-6DBBCEAF229C}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -313,6 +319,7 @@ Global {5F1E1662-D2D1-4325-BFE3-6AE23A8A4D7E} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B} {B894780C-338F-475E-8E84-56AFA8197A06} = {E5637F81-2FB9-4CD7-900D-455363B142A7} {6EB9D002-62C8-D6C1-62A8-14C54CA6DBBC} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} + {7C3ECBCE-BEFB-4982-842E-B654BB6B6285} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {FE1DA748-D6DB-E168-BC42-6DBBCEAF229C} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/eng/targets/Release.props b/eng/targets/Release.props index b4b9d7426..26bdc2d52 100644 --- a/eng/targets/Release.props +++ b/eng/targets/Release.props @@ -17,7 +17,7 @@ - 1.18.0 + 1.18.1 diff --git a/global.json b/global.json index 4b560e684..332b6c45b 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.100", + "version": "10.0.101", "rollForward": "latestFeature" }, "msbuild-sdks": { diff --git a/samples/AzureFunctionsApp/AzureFunctionsApp.csproj b/samples/AzureFunctionsApp/AzureFunctionsApp.csproj index 69f3ff811..ad64c303f 100644 --- a/samples/AzureFunctionsApp/AzureFunctionsApp.csproj +++ b/samples/AzureFunctionsApp/AzureFunctionsApp.csproj @@ -20,6 +20,7 @@ + PreserveNewest diff --git a/samples/ConsoleApp/ConsoleApp.csproj b/samples/ConsoleApp/ConsoleApp.csproj index 5de1fbdff..e9ab58119 100644 --- a/samples/ConsoleApp/ConsoleApp.csproj +++ b/samples/ConsoleApp/ConsoleApp.csproj @@ -20,6 +20,7 @@ + diff --git a/samples/ConsoleAppMinimal/ConsoleAppMinimal.csproj b/samples/ConsoleAppMinimal/ConsoleAppMinimal.csproj index 5de1fbdff..e9ab58119 100644 --- a/samples/ConsoleAppMinimal/ConsoleAppMinimal.csproj +++ b/samples/ConsoleAppMinimal/ConsoleAppMinimal.csproj @@ -20,6 +20,7 @@ + diff --git a/samples/ExceptionPropertiesSample/CustomExceptionPropertiesProvider.cs b/samples/ExceptionPropertiesSample/CustomExceptionPropertiesProvider.cs new file mode 100644 index 000000000..7273d1332 --- /dev/null +++ b/samples/ExceptionPropertiesSample/CustomExceptionPropertiesProvider.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Worker; + +namespace ExceptionPropertiesSample; + +/// +/// Custom exception properties provider that extracts additional properties from exceptions +/// to include in TaskFailureDetails for better diagnostics and error handling. +/// +public class CustomExceptionPropertiesProvider : IExceptionPropertiesProvider +{ + /// + /// Extracts custom properties from exceptions to enrich failure details. + /// + /// The exception to extract properties from. + /// + /// A dictionary of custom properties to include in the FailureDetails, + /// or null if no properties should be added for this exception type. + /// + public IDictionary? GetExceptionProperties(Exception exception) + { + return exception switch + { + BusinessValidationException businessEx => new Dictionary + { + ["ErrorCode"] = businessEx.ErrorCode, + ["StatusCode"] = businessEx.StatusCode, + ["Metadata"] = businessEx.Metadata, + }, + ArgumentOutOfRangeException argEx => new Dictionary + { + ["ParameterName"] = argEx.ParamName ?? string.Empty, + ["ActualValue"] = argEx.ActualValue?.ToString() ?? string.Empty, + }, + ArgumentNullException argNullEx => new Dictionary + { + ["ParameterName"] = argNullEx.ParamName ?? string.Empty, + }, + _ => null // No custom properties for other exception types + }; + } +} + diff --git a/samples/ExceptionPropertiesSample/CustomExceptions.cs b/samples/ExceptionPropertiesSample/CustomExceptions.cs new file mode 100644 index 000000000..19e91f0a6 --- /dev/null +++ b/samples/ExceptionPropertiesSample/CustomExceptions.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace ExceptionPropertiesSample; + +/// +/// Custom business exception that includes additional properties for better error diagnostics. +/// +public class BusinessValidationException : Exception +{ + public BusinessValidationException( + string message, + string? errorCode = null, + int? statusCode = null, + Dictionary? metadata = null) + : base(message) + { + this.ErrorCode = errorCode; + this.StatusCode = statusCode; + this.Metadata = metadata ?? new Dictionary(); + } + + /// + /// Gets the error code associated with this validation failure. + /// + public string? ErrorCode { get; } + + /// + /// Gets the HTTP status code that should be returned for this error. + /// + public int? StatusCode { get; } + + /// + /// Gets additional metadata about the validation failure. + /// + public Dictionary Metadata { get; } +} + diff --git a/samples/ExceptionPropertiesSample/ExceptionPropertiesSample.csproj b/samples/ExceptionPropertiesSample/ExceptionPropertiesSample.csproj new file mode 100644 index 000000000..ebc0466f2 --- /dev/null +++ b/samples/ExceptionPropertiesSample/ExceptionPropertiesSample.csproj @@ -0,0 +1,28 @@ + + + + Exe + net6.0;net8.0;net10.0 + enable + + + + + + + + + + + + + + + + + + + diff --git a/samples/ExceptionPropertiesSample/Program.cs b/samples/ExceptionPropertiesSample/Program.cs new file mode 100644 index 000000000..6d805ff8b --- /dev/null +++ b/samples/ExceptionPropertiesSample/Program.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This sample demonstrates how to use IExceptionPropertiesProvider to enrich +// TaskFailureDetails with custom exception properties for better diagnostics. + +using ExceptionPropertiesSample; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.Hosting; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + +string? schedulerConnectionString = builder.Configuration.GetValue("DURABLE_TASK_SCHEDULER_CONNECTION_STRING"); +bool useScheduler = !string.IsNullOrWhiteSpace(schedulerConnectionString); + +// Register the durable task client +if (useScheduler) +{ + builder.Services.AddDurableTaskClient(clientBuilder => clientBuilder.UseDurableTaskScheduler(schedulerConnectionString!)); +} +else +{ + builder.Services.AddDurableTaskClient().UseGrpc(); +} + +// Register the durable task worker with custom exception properties provider +if (useScheduler) +{ + builder.Services.AddDurableTaskWorker(workerBuilder => + { + workerBuilder.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + }); + + workerBuilder.UseDurableTaskScheduler(schedulerConnectionString!); + }); +} +else +{ + builder.Services.AddDurableTaskWorker() + .AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + }) + .UseGrpc(); +} + +// Register the custom exception properties provider +// This will automatically extract custom properties from exceptions and include them in TaskFailureDetails +builder.Services.AddSingleton(); + +IHost host = builder.Build(); + +// Start the worker +await host.StartAsync(); + +// Get the client to start orchestrations +DurableTaskClient client = host.Services.GetRequiredService(); + +Console.WriteLine("Exception Properties Sample"); +Console.WriteLine("==========================="); +Console.WriteLine(); + +Console.WriteLine(useScheduler + ? "Configured to use Durable Task Scheduler (DTS)." + : "Configured to use local gRPC. (Set DURABLE_TASK_SCHEDULER_CONNECTION_STRING to use DTS.)"); +Console.WriteLine(); + +// Test case 1: Valid input (should succeed) +Console.WriteLine("Test 1: Valid input"); +string instanceId1 = await client.ScheduleNewOrchestrationInstanceAsync( + "ValidationOrchestration", + input: "Hello World"); + +OrchestrationMetadata result1 = await client.WaitForInstanceCompletionAsync( + instanceId1, + getInputsAndOutputs: true); + +if (result1.RuntimeStatus == OrchestrationRuntimeStatus.Completed) +{ + Console.WriteLine($"✓ Orchestration completed successfully"); + Console.WriteLine($" Output: {result1.ReadOutputAs()}"); +} +Console.WriteLine(); + +// Test case 2: Empty input (should fail with custom properties) +Console.WriteLine("Test 2: Empty input (should fail)"); +string instanceId2 = await client.ScheduleNewOrchestrationInstanceAsync( + "ValidationOrchestration", + input: string.Empty); + +OrchestrationMetadata result2 = await client.WaitForInstanceCompletionAsync( + instanceId2, + getInputsAndOutputs: true); + +if (result2.RuntimeStatus == OrchestrationRuntimeStatus.Failed && result2.FailureDetails != null) +{ + Console.WriteLine($"✗ Orchestration failed as expected"); + Console.WriteLine($" Error Type: {result2.FailureDetails.ErrorType}"); + Console.WriteLine($" Error Message: {result2.FailureDetails.ErrorMessage}"); + + // Display custom properties that were extracted by IExceptionPropertiesProvider + if (result2.FailureDetails.Properties != null && result2.FailureDetails.Properties.Count > 0) + { + Console.WriteLine($" Custom Properties:"); + foreach (var property in result2.FailureDetails.Properties) + { + Console.WriteLine($" - {property.Key}: {property.Value}"); + } + } +} +Console.WriteLine(); + +// Test case 3: Short input (should fail with different custom properties) +Console.WriteLine("Test 3: Short input (should fail)"); +string instanceId3 = await client.ScheduleNewOrchestrationInstanceAsync( + "ValidationOrchestration", + input: "Hi"); + +OrchestrationMetadata result3 = await client.WaitForInstanceCompletionAsync( + instanceId3, + getInputsAndOutputs: true); + +if (result3.RuntimeStatus == OrchestrationRuntimeStatus.Failed && result3.FailureDetails != null) +{ + Console.WriteLine($"✗ Orchestration failed as expected"); + Console.WriteLine($" Error Type: {result3.FailureDetails.ErrorType}"); + Console.WriteLine($" Error Message: {result3.FailureDetails.ErrorMessage}"); + + // Display custom properties + if (result3.FailureDetails.Properties != null && result3.FailureDetails.Properties.Count > 0) + { + Console.WriteLine($" Custom Properties:"); + foreach (var property in result3.FailureDetails.Properties) + { + Console.WriteLine($" - {property.Key}: {property.Value}"); + } + } +} +Console.WriteLine(); + +Console.WriteLine("Sample completed. Press any key to exit..."); +Console.ReadKey(); + +await host.StopAsync(); + diff --git a/samples/ExceptionPropertiesSample/README.md b/samples/ExceptionPropertiesSample/README.md new file mode 100644 index 000000000..a9ff13db9 --- /dev/null +++ b/samples/ExceptionPropertiesSample/README.md @@ -0,0 +1,85 @@ +# Exception Properties Sample + +This sample demonstrates how to use `IExceptionPropertiesProvider` to enrich `TaskFailureDetails` with custom exception properties for better diagnostics and error handling. + +## Overview + +When orchestrations or activities throw exceptions, the Durable Task framework captures failure details. By implementing `IExceptionPropertiesProvider`, you can extract custom properties from exceptions and include them in the `TaskFailureDetails`, making it easier to diagnose issues and handle errors programmatically. + +## Key Concepts + +1. **Custom Exception with Properties**: Create exceptions that carry additional context (error codes, metadata, etc.) +2. **IExceptionPropertiesProvider**: Implement this interface to extract custom properties from exceptions +3. **Automatic Property Extraction**: The framework automatically uses your provider when converting exceptions to `TaskFailureDetails` +4. **Retrieving Failure Details**: Use the durable client to retrieve orchestration status and access the enriched failure details + +## What This Sample Does + +1. Defines a `BusinessValidationException` with custom properties (ErrorCode, StatusCode, Metadata) +2. Implements `CustomExceptionPropertiesProvider` that extracts these properties from exceptions +3. Creates a validation orchestration and activity that throws the custom exception +4. Demonstrates how to retrieve and display failure details with custom properties using the durable client + +## Running the Sample + +This sample can run against either: + +1. **Durable Task Scheduler (DTS)** (recommended): set the `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` environment variable. +2. **Local gRPC endpoint**: if the env var is not set, the sample uses the default local gRPC configuration. + +### DTS + +Set `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` and run the sample. + +```cmd +set DURABLE_TASK_SCHEDULER_CONNECTION_STRING=Endpoint=https://...;TaskHub=...;Authentication=...; +dotnet run --project samples/ExceptionPropertiesSample/ExceptionPropertiesSample.csproj +``` + +```bash +export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=https://...;TaskHub=...;Authentication=...;" +dotnet run --project samples/ExceptionPropertiesSample/ExceptionPropertiesSample.csproj +``` + +## Expected Output + +The sample runs three test cases: +1. **Valid input**: Orchestration completes successfully +2. **Empty input**: Orchestration fails with custom properties (ErrorCode, StatusCode, Metadata) +3. **Short input**: Orchestration fails with different custom properties + +For failed orchestrations, you'll see the custom properties extracted by the `IExceptionPropertiesProvider` displayed in the console. + +## Code Structure + +- `CustomExceptions.cs`: Defines the `BusinessValidationException` with custom properties +- `CustomExceptionPropertiesProvider.cs`: Implements `IExceptionPropertiesProvider` to extract properties +- `Tasks.cs`: Contains the orchestration and activity that throw custom exceptions +- `Program.cs`: Sets up the worker, registers the provider, and demonstrates retrieving failure details + +## Key Code Snippet + +```csharp +// Register the custom exception properties provider +builder.Services.AddSingleton(); + +// Retrieve failure details with custom properties +OrchestrationMetadata result = await client.WaitForInstanceCompletionAsync( + instanceId, + getInputsAndOutputs: true); // Important: must be true to get failure details + +if (result.FailureDetails?.Properties != null) +{ + foreach (var property in result.FailureDetails.Properties) + { + Console.WriteLine($"{property.Key}: {property.Value}"); + } +} +``` + +## Notes + +- The `getInputsAndOutputs` parameter must be `true` when calling `GetInstanceAsync` or `WaitForInstanceCompletionAsync` to retrieve failure details +- Custom properties are only included if the orchestration is in a `Failed` state +- The `IExceptionPropertiesProvider` is called automatically by the framework when exceptions are caught + diff --git a/samples/ExceptionPropertiesSample/Tasks.cs b/samples/ExceptionPropertiesSample/Tasks.cs new file mode 100644 index 000000000..50c7d289d --- /dev/null +++ b/samples/ExceptionPropertiesSample/Tasks.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask; + +namespace ExceptionPropertiesSample; + +/// +/// Orchestration that demonstrates custom exception properties in failure details. +/// +[DurableTask("ValidationOrchestration")] +public class ValidationOrchestration : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string input) + { + // Call an activity that may throw a custom exception with properties + string result = await context.CallActivityAsync("ValidateInput", input); + return result; + } +} + +/// +/// Activity that validates input and throws a custom exception with properties on failure. +/// +[DurableTask("ValidateInput")] +public class ValidateInputActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, string input) + { + // Simulate validation logic + if (string.IsNullOrWhiteSpace(input)) + { + throw new BusinessValidationException( + message: "Input validation failed: input cannot be empty", + errorCode: "VALIDATION_001", + statusCode: 400, + metadata: new Dictionary + { + ["Field"] = "input", + ["ValidationRule"] = "Required", + ["Timestamp"] = DateTime.UtcNow, + }); + } + + if (input.Length < 3) + { + throw new BusinessValidationException( + message: $"Input validation failed: input must be at least 3 characters (received {input.Length})", + errorCode: "VALIDATION_002", + statusCode: 400, + metadata: new Dictionary + { + ["Field"] = "input", + ["ValidationRule"] = "MinLength", + ["MinLength"] = 3, + ["ActualLength"] = input.Length, + }); + } + + // Validation passed + return Task.FromResult($"Validation successful for input: {input}"); + } +} + diff --git a/samples/LargePayloadConsoleApp/LargePayloadConsoleApp.csproj b/samples/LargePayloadConsoleApp/LargePayloadConsoleApp.csproj index c7ac5d2ed..e1f3e5414 100644 --- a/samples/LargePayloadConsoleApp/LargePayloadConsoleApp.csproj +++ b/samples/LargePayloadConsoleApp/LargePayloadConsoleApp.csproj @@ -17,8 +17,7 @@ + - - diff --git a/samples/NetFxConsoleApp/NetFxConsoleApp.csproj b/samples/NetFxConsoleApp/NetFxConsoleApp.csproj index 2a6a04e3e..d73763210 100644 --- a/samples/NetFxConsoleApp/NetFxConsoleApp.csproj +++ b/samples/NetFxConsoleApp/NetFxConsoleApp.csproj @@ -6,6 +6,7 @@ + diff --git a/samples/ScheduleConsoleApp/Orchestrators/StockPriceOrchestrator.cs b/samples/ScheduleConsoleApp/Orchestrators/StockPriceOrchestrator.cs index 3cc04a60f..18fb75322 100644 --- a/samples/ScheduleConsoleApp/Orchestrators/StockPriceOrchestrator.cs +++ b/samples/ScheduleConsoleApp/Orchestrators/StockPriceOrchestrator.cs @@ -18,7 +18,7 @@ public override async Task RunAsync(TaskOrchestrationContext context, st logger.LogInformation("Current price for {symbol} is ${price:F2}", symbol, currentPrice); - return $"Stock {symbol} price: ${currentPrice:F2} at {DateTime.UtcNow}"; + return $"Stock {symbol} price: ${currentPrice:F2} at {context.CurrentUtcDateTime}"; } catch (Exception ex) { diff --git a/samples/ScheduleConsoleApp/ScheduleConsoleApp.csproj b/samples/ScheduleConsoleApp/ScheduleConsoleApp.csproj index 5c356d93b..227ee159d 100644 --- a/samples/ScheduleConsoleApp/ScheduleConsoleApp.csproj +++ b/samples/ScheduleConsoleApp/ScheduleConsoleApp.csproj @@ -17,5 +17,6 @@ + diff --git a/samples/ScheduleWebApp/ScheduleWebApp.csproj b/samples/ScheduleWebApp/ScheduleWebApp.csproj index a505e3df4..3d426a2f7 100644 --- a/samples/ScheduleWebApp/ScheduleWebApp.csproj +++ b/samples/ScheduleWebApp/ScheduleWebApp.csproj @@ -18,5 +18,6 @@ + diff --git a/src/Abstractions/TaskOptions.cs b/src/Abstractions/TaskOptions.cs index f8e527fe1..c38144b61 100644 --- a/src/Abstractions/TaskOptions.cs +++ b/src/Abstractions/TaskOptions.cs @@ -134,4 +134,12 @@ public record StartOrchestrationOptions(string? InstanceId = null, DateTimeOffse /// Gets the version to associate with the orchestration instance. /// public TaskVersion? Version { get; init; } + + /// + /// Gets the orchestration runtime statuses that should be considered for deduplication. + /// + /// + /// For type-safe usage, use the WithDedupeStatuses extension method. + /// + public IReadOnlyList? DedupeStatuses { get; init; } } diff --git a/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs b/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs index b1222d4bc..1bf920a4d 100644 --- a/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs +++ b/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs @@ -39,7 +39,8 @@ public sealed class FunctionNotFoundAnalyzer : DiagnosticAnalyzer AnalyzersCategories.Activity, DiagnosticSeverity.Warning, customTags: [WellKnownDiagnosticTags.CompilationEnd], - isEnabledByDefault: true); + isEnabledByDefault: true, + helpLinkUri: "https://go.microsoft.com/fwlink/?linkid=2346202"); static readonly DiagnosticDescriptor SubOrchestrationNotFoundRule = new( SubOrchestrationNotFoundDiagnosticId, @@ -48,7 +49,8 @@ public sealed class FunctionNotFoundAnalyzer : DiagnosticAnalyzer AnalyzersCategories.Orchestration, DiagnosticSeverity.Warning, customTags: [WellKnownDiagnosticTags.CompilationEnd], - isEnabledByDefault: true); + isEnabledByDefault: true, + helpLinkUri: "https://go.microsoft.com/fwlink/?linkid=2346202"); /// public override ImmutableArray SupportedDiagnostics => [ActivityNotFoundRule, SubOrchestrationNotFoundRule]; diff --git a/src/Analyzers/Activities/MatchingInputOutputTypeActivityAnalyzer.cs b/src/Analyzers/Activities/MatchingInputOutputTypeActivityAnalyzer.cs index ebdaac79a..63da59e7b 100644 --- a/src/Analyzers/Activities/MatchingInputOutputTypeActivityAnalyzer.cs +++ b/src/Analyzers/Activities/MatchingInputOutputTypeActivityAnalyzer.cs @@ -40,7 +40,8 @@ public class MatchingInputOutputTypeActivityAnalyzer : DiagnosticAnalyzer AnalyzersCategories.Activity, DiagnosticSeverity.Warning, customTags: [WellKnownDiagnosticTags.CompilationEnd], - isEnabledByDefault: true); + isEnabledByDefault: true, + helpLinkUri: "https://go.microsoft.com/fwlink/?linkid=2346202"); static readonly DiagnosticDescriptor OutputArgumentTypeMismatchRule = new( OutputArgumentTypeMismatchDiagnosticId, @@ -49,7 +50,8 @@ public class MatchingInputOutputTypeActivityAnalyzer : DiagnosticAnalyzer AnalyzersCategories.Activity, DiagnosticSeverity.Warning, customTags: [WellKnownDiagnosticTags.CompilationEnd], - isEnabledByDefault: true); + isEnabledByDefault: true, + helpLinkUri: "https://go.microsoft.com/fwlink/?linkid=2346202"); /// public override ImmutableArray SupportedDiagnostics => [InputArgumentTypeMismatchRule, OutputArgumentTypeMismatchRule]; diff --git a/src/Analyzers/AnalyzerReleases.Shipped.md b/src/Analyzers/AnalyzerReleases.Shipped.md index c25049810..b3ea2041f 100644 --- a/src/Analyzers/AnalyzerReleases.Shipped.md +++ b/src/Analyzers/AnalyzerReleases.Shipped.md @@ -1,6 +1,15 @@ ; Shipped analyzer releases ; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md +## Release 0.2.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +DURABLE2003 | Activity | Warning | **FunctionNotFoundAnalyzer**: Warns when an activity function call references a name that does not match any defined activity in the compilation. +DURABLE2004 | Orchestration | Warning | **FunctionNotFoundAnalyzer**: Warns when a sub-orchestration call references a name that does not match any defined orchestrator in the compilation. + ## Release 0.1.0 ### New Rules diff --git a/src/Analyzers/AnalyzerReleases.Unshipped.md b/src/Analyzers/AnalyzerReleases.Unshipped.md index 54c608f8b..0317d4712 100644 --- a/src/Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Analyzers/AnalyzerReleases.Unshipped.md @@ -6,5 +6,3 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- DURABLE0009 | Orchestration | Info | **GetInputOrchestrationAnalyzer**: Suggests using input parameter binding instead of ctx.GetInput() in orchestration methods. -DURABLE2003 | Activity | Warning | **FunctionNotFoundAnalyzer**: Warns when an activity function call references a name that does not match any defined activity in the compilation. -DURABLE2004 | Orchestration | Warning | **FunctionNotFoundAnalyzer**: Warns when a sub-orchestration call references a name that does not match any defined orchestrator in the compilation. \ No newline at end of file diff --git a/src/Analyzers/Analyzers.csproj b/src/Analyzers/Analyzers.csproj index dad1b98a3..be79830c6 100644 --- a/src/Analyzers/Analyzers.csproj +++ b/src/Analyzers/Analyzers.csproj @@ -11,7 +11,7 @@ - 0.1.0 + 0.2.0 .NET Analyzers for the Durable Task SDK. en diff --git a/src/Analyzers/Functions/AttributeBinding/DurableClientBindingAnalyzer.cs b/src/Analyzers/Functions/AttributeBinding/DurableClientBindingAnalyzer.cs index 8089043ee..27d96c581 100644 --- a/src/Analyzers/Functions/AttributeBinding/DurableClientBindingAnalyzer.cs +++ b/src/Analyzers/Functions/AttributeBinding/DurableClientBindingAnalyzer.cs @@ -27,7 +27,8 @@ public sealed class DurableClientBindingAnalyzer : MatchingAttributeBindingAnaly MessageFormat, AnalyzersCategories.AttributeBinding, DiagnosticSeverity.Error, - isEnabledByDefault: true); + isEnabledByDefault: true, + helpLinkUri: "https://go.microsoft.com/fwlink/?linkid=2346202"); /// public override ImmutableArray SupportedDiagnostics => [Rule]; diff --git a/src/Analyzers/Functions/AttributeBinding/EntityTriggerBindingAnalyzer.cs b/src/Analyzers/Functions/AttributeBinding/EntityTriggerBindingAnalyzer.cs index 8667cb32a..59513a829 100644 --- a/src/Analyzers/Functions/AttributeBinding/EntityTriggerBindingAnalyzer.cs +++ b/src/Analyzers/Functions/AttributeBinding/EntityTriggerBindingAnalyzer.cs @@ -27,7 +27,8 @@ public sealed class EntityTriggerBindingAnalyzer : MatchingAttributeBindingAnaly MessageFormat, AnalyzersCategories.AttributeBinding, DiagnosticSeverity.Error, - isEnabledByDefault: true); + isEnabledByDefault: true, + helpLinkUri: "https://go.microsoft.com/fwlink/?linkid=2346202"); /// public override ImmutableArray SupportedDiagnostics => [Rule]; diff --git a/src/Analyzers/Functions/AttributeBinding/OrchestrationTriggerBindingAnalyzer.cs b/src/Analyzers/Functions/AttributeBinding/OrchestrationTriggerBindingAnalyzer.cs index d66420438..603a92f60 100644 --- a/src/Analyzers/Functions/AttributeBinding/OrchestrationTriggerBindingAnalyzer.cs +++ b/src/Analyzers/Functions/AttributeBinding/OrchestrationTriggerBindingAnalyzer.cs @@ -27,7 +27,8 @@ public sealed class OrchestrationTriggerBindingAnalyzer : MatchingAttributeBindi MessageFormat, AnalyzersCategories.AttributeBinding, DiagnosticSeverity.Error, - isEnabledByDefault: true); + isEnabledByDefault: true, + helpLinkUri: "https://go.microsoft.com/fwlink/?linkid=2346202"); /// public override ImmutableArray SupportedDiagnostics => [Rule]; diff --git a/src/Analyzers/Functions/Orchestration/CancellationTokenOrchestrationAnalyzer.cs b/src/Analyzers/Functions/Orchestration/CancellationTokenOrchestrationAnalyzer.cs index 738f4f7d4..503a30050 100644 --- a/src/Analyzers/Functions/Orchestration/CancellationTokenOrchestrationAnalyzer.cs +++ b/src/Analyzers/Functions/Orchestration/CancellationTokenOrchestrationAnalyzer.cs @@ -30,7 +30,8 @@ public class CancellationTokenOrchestrationAnalyzer : OrchestrationAnalyzer public override ImmutableArray SupportedDiagnostics => [Rule]; diff --git a/src/Analyzers/Functions/Orchestration/OtherBindingsOrchestrationAnalyzer.cs b/src/Analyzers/Functions/Orchestration/OtherBindingsOrchestrationAnalyzer.cs index 6cea911d8..9e512af6a 100644 --- a/src/Analyzers/Functions/Orchestration/OtherBindingsOrchestrationAnalyzer.cs +++ b/src/Analyzers/Functions/Orchestration/OtherBindingsOrchestrationAnalyzer.cs @@ -30,7 +30,8 @@ class OtherBindingsOrchestrationAnalyzer : OrchestrationAnalyzer public override ImmutableArray SupportedDiagnostics => [Rule]; diff --git a/src/Analyzers/Orchestration/DateTimeOrchestrationAnalyzer.cs b/src/Analyzers/Orchestration/DateTimeOrchestrationAnalyzer.cs index 7eefeb675..ccfc81ed9 100644 --- a/src/Analyzers/Orchestration/DateTimeOrchestrationAnalyzer.cs +++ b/src/Analyzers/Orchestration/DateTimeOrchestrationAnalyzer.cs @@ -10,7 +10,7 @@ namespace Microsoft.DurableTask.Analyzers.Orchestration; /// -/// Analyzer that reports a warning when a non-deterministic DateTime property is used in an orchestration method. +/// Analyzer that reports a warning when a non-deterministic DateTime or DateTimeOffset property is used in an orchestration method. /// [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class DateTimeOrchestrationAnalyzer : OrchestrationAnalyzer @@ -29,22 +29,25 @@ public sealed class DateTimeOrchestrationAnalyzer : OrchestrationAnalyzer public override ImmutableArray SupportedDiagnostics => [Rule]; /// - /// Visitor that inspects the method body for DateTime properties. + /// Visitor that inspects the method body for DateTime and DateTimeOffset properties. /// public sealed class DateTimeOrchestrationVisitor : MethodProbeOrchestrationVisitor { INamedTypeSymbol systemDateTimeSymbol = null!; + INamedTypeSymbol? systemDateTimeOffsetSymbol; /// public override bool Initialize() { this.systemDateTimeSymbol = this.Compilation.GetSpecialType(SpecialType.System_DateTime); + this.systemDateTimeOffsetSymbol = this.Compilation.GetTypeByMetadataName("System.DateTimeOffset"); return true; } @@ -61,14 +64,25 @@ protected override void VisitMethod(SemanticModel semanticModel, SyntaxNode meth { IPropertySymbol property = operation.Property; - if (!property.ContainingSymbol.Equals(this.systemDateTimeSymbol, SymbolEqualityComparer.Default)) + bool isDateTime = property.ContainingSymbol.Equals(this.systemDateTimeSymbol, SymbolEqualityComparer.Default); + bool isDateTimeOffset = this.systemDateTimeOffsetSymbol is not null && + property.ContainingSymbol.Equals(this.systemDateTimeOffsetSymbol, SymbolEqualityComparer.Default); + + if (!isDateTime && !isDateTimeOffset) { - return; + continue; } - if (property.Name is nameof(DateTime.Now) or nameof(DateTime.UtcNow) or nameof(DateTime.Today)) + // Check for non-deterministic properties + // DateTime has: Now, UtcNow, Today + // DateTimeOffset has: Now, UtcNow (but not Today) + bool isNonDeterministic = property.Name is nameof(DateTime.Now) or nameof(DateTime.UtcNow) || + (isDateTime && property.Name == nameof(DateTime.Today)); + + if (isNonDeterministic) { - // e.g.: "The method 'Method1' uses 'System.Date.Now' that may cause non-deterministic behavior when invoked from orchestration 'MyOrchestrator'" + // e.g.: "The method 'Method1' uses 'System.DateTime.Now' that may cause non-deterministic behavior when invoked from orchestration 'MyOrchestrator'" + // e.g.: "The method 'Method1' uses 'System.DateTimeOffset.Now' that may cause non-deterministic behavior when invoked from orchestration 'MyOrchestrator'" reportDiagnostic(RoslynExtensions.BuildDiagnostic(Rule, operation.Syntax, methodSymbol.Name, property.ToString(), orchestrationName)); } } diff --git a/src/Analyzers/Orchestration/DateTimeOrchestrationFixer.cs b/src/Analyzers/Orchestration/DateTimeOrchestrationFixer.cs index 5b32857c7..fd233d1bb 100644 --- a/src/Analyzers/Orchestration/DateTimeOrchestrationFixer.cs +++ b/src/Analyzers/Orchestration/DateTimeOrchestrationFixer.cs @@ -26,7 +26,7 @@ public sealed class DateTimeOrchestrationFixer : OrchestrationContextFixer /// protected override void RegisterCodeFixes(CodeFixContext context, OrchestrationCodeFixContext orchestrationContext) { - // Parses the syntax node to see if it is a member access expression (e.g. DateTime.Now) + // Parses the syntax node to see if it is a member access expression (e.g. DateTime.Now or DateTimeOffset.Now) if (orchestrationContext.SyntaxNodeWithDiagnostic is not MemberAccessExpressionSyntax dateTimeExpression) { return; @@ -35,12 +35,30 @@ protected override void RegisterCodeFixes(CodeFixContext context, OrchestrationC // Gets the name of the TaskOrchestrationContext parameter (e.g. "context" or "ctx") string contextParameterName = orchestrationContext.TaskOrchestrationContextSymbol.Name; + // Use semantic analysis to determine if this is a DateTimeOffset expression + SemanticModel semanticModel = orchestrationContext.SemanticModel; + ITypeSymbol? typeSymbol = semanticModel.GetTypeInfo(dateTimeExpression.Expression).Type; + bool isDateTimeOffset = typeSymbol?.ToDisplayString() == "System.DateTimeOffset"; + bool isDateTimeToday = dateTimeExpression.Name.ToString() == "Today"; - string dateTimeTodaySuffix = isDateTimeToday ? ".Date" : string.Empty; - string recommendation = $"{contextParameterName}.CurrentUtcDateTime{dateTimeTodaySuffix}"; + + // Build the recommendation text + string recommendation; + if (isDateTimeOffset) + { + // For DateTimeOffset, we always just cast CurrentUtcDateTime + recommendation = $"(DateTimeOffset){contextParameterName}.CurrentUtcDateTime"; + } + else + { + // For DateTime, we may need to add .Date for Today + string dateTimeTodaySuffix = isDateTimeToday ? ".Date" : string.Empty; + recommendation = $"{contextParameterName}.CurrentUtcDateTime{dateTimeTodaySuffix}"; + } // e.g: "Use 'context.CurrentUtcDateTime' instead of 'DateTime.Now'" // e.g: "Use 'context.CurrentUtcDateTime.Date' instead of 'DateTime.Today'" + // e.g: "Use '(DateTimeOffset)context.CurrentUtcDateTime' instead of 'DateTimeOffset.Now'" string title = string.Format( CultureInfo.InvariantCulture, Resources.UseInsteadFixerTitle, @@ -50,15 +68,15 @@ protected override void RegisterCodeFixes(CodeFixContext context, OrchestrationC context.RegisterCodeFix( CodeAction.Create( title: title, - createChangedDocument: c => ReplaceDateTime(context.Document, orchestrationContext.Root, dateTimeExpression, contextParameterName, isDateTimeToday), + createChangedDocument: c => ReplaceDateTime(context.Document, orchestrationContext.Root, dateTimeExpression, contextParameterName, isDateTimeToday, isDateTimeOffset), equivalenceKey: title), // This key is used to prevent duplicate code fixes. context.Diagnostics); } - static Task ReplaceDateTime(Document document, SyntaxNode oldRoot, MemberAccessExpressionSyntax incorrectDateTimeSyntax, string contextParameterName, bool isDateTimeToday) + static Task ReplaceDateTime(Document document, SyntaxNode oldRoot, MemberAccessExpressionSyntax incorrectDateTimeSyntax, string contextParameterName, bool isDateTimeToday, bool isDateTimeOffset) { // Builds a 'context.CurrentUtcDateTime' syntax node - MemberAccessExpressionSyntax correctDateTimeSyntax = + ExpressionSyntax correctDateTimeSyntax = MemberAccessExpression( SyntaxKind.SimpleMemberAccessExpression, IdentifierName(contextParameterName), @@ -73,6 +91,15 @@ static Task ReplaceDateTime(Document document, SyntaxNode oldRoot, Mem IdentifierName("Date")); } + // If the original expression was DateTimeOffset, we need to cast the DateTime to DateTimeOffset + // This is done using a CastExpression: (DateTimeOffset)context.CurrentUtcDateTime + if (isDateTimeOffset) + { + correctDateTimeSyntax = CastExpression( + IdentifierName("DateTimeOffset"), + correctDateTimeSyntax); + } + // Replaces the old local declaration with the new local declaration. SyntaxNode newRoot = oldRoot.ReplaceNode(incorrectDateTimeSyntax, correctDateTimeSyntax); Document newDocument = document.WithSyntaxRoot(newRoot); diff --git a/src/Analyzers/Orchestration/DelayOrchestrationAnalyzer.cs b/src/Analyzers/Orchestration/DelayOrchestrationAnalyzer.cs index 5fcef63a1..5674db580 100644 --- a/src/Analyzers/Orchestration/DelayOrchestrationAnalyzer.cs +++ b/src/Analyzers/Orchestration/DelayOrchestrationAnalyzer.cs @@ -29,7 +29,8 @@ public class DelayOrchestrationAnalyzer : OrchestrationAnalyzer public override ImmutableArray SupportedDiagnostics => [Rule]; diff --git a/src/Analyzers/Orchestration/EnvironmentOrchestrationAnalyzer.cs b/src/Analyzers/Orchestration/EnvironmentOrchestrationAnalyzer.cs index 3e4fb7739..c64ea76b7 100644 --- a/src/Analyzers/Orchestration/EnvironmentOrchestrationAnalyzer.cs +++ b/src/Analyzers/Orchestration/EnvironmentOrchestrationAnalyzer.cs @@ -31,7 +31,8 @@ public sealed class EnvironmentOrchestrationAnalyzer : OrchestrationAnalyzer public override ImmutableArray SupportedDiagnostics => [Rule]; diff --git a/src/Analyzers/Orchestration/GuidOrchestrationAnalyzer.cs b/src/Analyzers/Orchestration/GuidOrchestrationAnalyzer.cs index 2b0c4c719..2ba936bf4 100644 --- a/src/Analyzers/Orchestration/GuidOrchestrationAnalyzer.cs +++ b/src/Analyzers/Orchestration/GuidOrchestrationAnalyzer.cs @@ -29,7 +29,8 @@ public sealed class GuidOrchestrationAnalyzer : OrchestrationAnalyzer public override ImmutableArray SupportedDiagnostics => [Rule]; diff --git a/src/Analyzers/Orchestration/IOOrchestrationAnalyzer.cs b/src/Analyzers/Orchestration/IOOrchestrationAnalyzer.cs index 6051ccc57..1f0c1e34e 100644 --- a/src/Analyzers/Orchestration/IOOrchestrationAnalyzer.cs +++ b/src/Analyzers/Orchestration/IOOrchestrationAnalyzer.cs @@ -30,7 +30,8 @@ public sealed class IOOrchestrationAnalyzer : OrchestrationAnalyzer public override ImmutableArray SupportedDiagnostics => [Rule]; diff --git a/src/Analyzers/Orchestration/OrchestrationAnalyzer.cs b/src/Analyzers/Orchestration/OrchestrationAnalyzer.cs index a299d5777..bfffb57a2 100644 --- a/src/Analyzers/Orchestration/OrchestrationAnalyzer.cs +++ b/src/Analyzers/Orchestration/OrchestrationAnalyzer.cs @@ -27,11 +27,14 @@ public override void Initialize(AnalysisContext context) { KnownTypeSymbols knownSymbols = new(context.Compilation); - if (knownSymbols.FunctionOrchestrationAttribute == null || knownSymbols.FunctionNameAttribute == null || - knownSymbols.TaskOrchestratorInterface == null || - knownSymbols.DurableTaskRegistry == null) + // Check if at least one orchestration type can be detected + bool canAnalyzeDurableFunctions = knownSymbols.FunctionOrchestrationAttribute != null && knownSymbols.FunctionNameAttribute != null; + bool canAnalyzeTaskOrchestrator = knownSymbols.TaskOrchestratorInterface != null && knownSymbols.TaskOrchestrationContext != null; + bool canAnalyzeFuncOrchestrator = knownSymbols.DurableTaskRegistry != null; + + if (!canAnalyzeDurableFunctions && !canAnalyzeTaskOrchestrator && !canAnalyzeFuncOrchestrator) { - // symbols not available in this compilation, skip analysis + // no symbols available in this compilation, skip analysis return; } @@ -42,124 +45,133 @@ public override void Initialize(AnalysisContext context) } // look for Durable Functions Orchestrations - context.RegisterSyntaxNodeAction( - ctx => + if (canAnalyzeDurableFunctions) { - ctx.CancellationToken.ThrowIfCancellationRequested(); - - if (ctx.ContainingSymbol is not IMethodSymbol methodSymbol) + context.RegisterSyntaxNodeAction( + ctx => { - return; - } + ctx.CancellationToken.ThrowIfCancellationRequested(); - if (!methodSymbol.ContainsAttributeInAnyMethodArguments(knownSymbols.FunctionOrchestrationAttribute)) - { - return; - } + if (ctx.ContainingSymbol is not IMethodSymbol methodSymbol) + { + return; + } - if (!methodSymbol.TryGetSingleValueFromAttribute(knownSymbols.FunctionNameAttribute, out string functionName)) - { - return; - } + if (!methodSymbol.ContainsAttributeInAnyMethodArguments(knownSymbols.FunctionOrchestrationAttribute!)) + { + return; + } + + if (!methodSymbol.TryGetSingleValueFromAttribute(knownSymbols.FunctionNameAttribute!, out string functionName)) + { + return; + } - var rootMethodSyntax = (MethodDeclarationSyntax)ctx.Node; + var rootMethodSyntax = (MethodDeclarationSyntax)ctx.Node; - visitor.VisitDurableFunction(ctx.SemanticModel, rootMethodSyntax, methodSymbol, functionName, ctx.ReportDiagnostic); - }, - SyntaxKind.MethodDeclaration); + visitor.VisitDurableFunction(ctx.SemanticModel, rootMethodSyntax, methodSymbol, functionName, ctx.ReportDiagnostic); + }, + SyntaxKind.MethodDeclaration); + } // look for ITaskOrchestrator/TaskOrchestrator`2 Orchestrations - context.RegisterSyntaxNodeAction( - ctx => + if (canAnalyzeTaskOrchestrator) { - ctx.CancellationToken.ThrowIfCancellationRequested(); - - if (ctx.ContainingSymbol is not INamedTypeSymbol classSymbol) + context.RegisterSyntaxNodeAction( + ctx => { - return; - } + ctx.CancellationToken.ThrowIfCancellationRequested(); - bool implementsITaskOrchestrator = classSymbol.AllInterfaces.Any(i => i.Equals(knownSymbols.TaskOrchestratorInterface, SymbolEqualityComparer.Default)); - if (!implementsITaskOrchestrator) - { - return; - } + if (ctx.ContainingSymbol is not INamedTypeSymbol classSymbol) + { + return; + } - IEnumerable orchestrationMethods = classSymbol.GetMembers().OfType() - .Where(m => m.Parameters.Any(p => p.Type.Equals(knownSymbols.TaskOrchestrationContext, SymbolEqualityComparer.Default))); + bool implementsITaskOrchestrator = classSymbol.AllInterfaces.Any(i => i.Equals(knownSymbols.TaskOrchestratorInterface, SymbolEqualityComparer.Default)); + if (!implementsITaskOrchestrator) + { + return; + } - string functionName = classSymbol.Name; + IEnumerable orchestrationMethods = classSymbol.GetMembers().OfType() + .Where(m => m.Parameters.Any(p => p.Type.Equals(knownSymbols.TaskOrchestrationContext, SymbolEqualityComparer.Default))); - foreach (IMethodSymbol? methodSymbol in orchestrationMethods) - { - IEnumerable methodSyntaxes = methodSymbol.GetSyntaxNodes(); - foreach (MethodDeclarationSyntax rootMethodSyntax in methodSyntaxes) + string functionName = classSymbol.Name; + + foreach (IMethodSymbol? methodSymbol in orchestrationMethods) { - visitor.VisitTaskOrchestrator(ctx.SemanticModel, rootMethodSyntax, methodSymbol, functionName, ctx.ReportDiagnostic); + IEnumerable methodSyntaxes = methodSymbol.GetSyntaxNodes(); + foreach (MethodDeclarationSyntax rootMethodSyntax in methodSyntaxes) + { + visitor.VisitTaskOrchestrator(ctx.SemanticModel, rootMethodSyntax, methodSymbol, functionName, ctx.ReportDiagnostic); + } } - } - }, - SyntaxKind.ClassDeclaration); + }, + SyntaxKind.ClassDeclaration); + } // look for OrchestratorFunc Orchestrations - context.RegisterOperationAction( - ctx => + if (canAnalyzeFuncOrchestrator) { - if (ctx.Operation is not IInvocationOperation invocation) + context.RegisterOperationAction( + ctx => { - return; - } + if (ctx.Operation is not IInvocationOperation invocation) + { + return; + } - if (!SymbolEqualityComparer.Default.Equals(invocation.Type, knownSymbols.DurableTaskRegistry)) - { - return; - } + if (!SymbolEqualityComparer.Default.Equals(invocation.Type, knownSymbols.DurableTaskRegistry)) + { + return; + } - // there are 8 AddOrchestratorFunc overloads - if (invocation.TargetMethod.Name != "AddOrchestratorFunc") - { - return; - } + // there are 8 AddOrchestratorFunc overloads + if (invocation.TargetMethod.Name != "AddOrchestratorFunc") + { + return; + } - // all overloads have the parameter 'orchestrator', either as an Action or a Func - IArgumentOperation orchestratorArgument = invocation.Arguments.First(a => a.Parameter!.Name == "orchestrator"); - if (orchestratorArgument.Value is not IDelegateCreationOperation delegateCreationOperation) - { - return; - } + // all overloads have the parameter 'orchestrator', either as an Action or a Func + IArgumentOperation orchestratorArgument = invocation.Arguments.First(a => a.Parameter!.Name == "orchestrator"); + if (orchestratorArgument.Value is not IDelegateCreationOperation delegateCreationOperation) + { + return; + } - // obtains the method symbol from the delegate creation operation - IMethodSymbol? methodSymbol = null; - SyntaxNode? methodSyntax = null; - switch (delegateCreationOperation.Target) - { - case IAnonymousFunctionOperation lambdaOperation: - // use the containing symbol of the lambda (e.g. the class declaring it) as the method symbol - methodSymbol = ctx.ContainingSymbol as IMethodSymbol; - methodSyntax = delegateCreationOperation.Syntax; - break; - case IMethodReferenceOperation methodReferenceOperation: - // use the method reference as the method symbol - methodSymbol = methodReferenceOperation.Method; - methodSyntax = methodReferenceOperation.Method.DeclaringSyntaxReferences.First().GetSyntax(); - break; - default: - break; - } - - if (methodSymbol == null || methodSyntax == null) - { - return; - } + // obtains the method symbol from the delegate creation operation + IMethodSymbol? methodSymbol = null; + SyntaxNode? methodSyntax = null; + switch (delegateCreationOperation.Target) + { + case IAnonymousFunctionOperation _: + // use the containing symbol of the lambda (e.g. the class declaring it) as the method symbol + methodSymbol = ctx.ContainingSymbol as IMethodSymbol; + methodSyntax = delegateCreationOperation.Syntax; + break; + case IMethodReferenceOperation methodReferenceOperation: + // use the method reference as the method symbol + methodSymbol = methodReferenceOperation.Method; + methodSyntax = methodReferenceOperation.Method.DeclaringSyntaxReferences.First().GetSyntax(); + break; + default: + break; + } - // try to get the name of the orchestration from the method call, otherwise use the containing type name - IArgumentOperation nameArgument = invocation.Arguments.First(a => a.Parameter!.Name == "name"); - Optional name = nameArgument.GetConstantValueFromAttribute(ctx.Operation.SemanticModel!, ctx.CancellationToken); - string orchestrationName = name.Value?.ToString() ?? methodSymbol.Name; + if (methodSymbol == null || methodSyntax == null) + { + return; + } - visitor.VisitFuncOrchestrator(ctx.Operation.SemanticModel!, methodSyntax, methodSymbol, orchestrationName, ctx.ReportDiagnostic); - }, - OperationKind.Invocation); + // try to get the name of the orchestration from the method call, otherwise use the containing type name + IArgumentOperation nameArgument = invocation.Arguments.First(a => a.Parameter!.Name == "name"); + Optional name = nameArgument.GetConstantValueFromAttribute(ctx.Operation.SemanticModel!, ctx.CancellationToken); + string orchestrationName = name.Value?.ToString() ?? methodSymbol.Name; + + visitor.VisitFuncOrchestrator(ctx.Operation.SemanticModel!, methodSyntax, methodSymbol, orchestrationName, ctx.ReportDiagnostic); + }, + OperationKind.Invocation); + } }); } } diff --git a/src/Analyzers/Orchestration/ThreadTaskOrchestrationAnalyzer.cs b/src/Analyzers/Orchestration/ThreadTaskOrchestrationAnalyzer.cs index 8d640ed1c..4fbed41b3 100644 --- a/src/Analyzers/Orchestration/ThreadTaskOrchestrationAnalyzer.cs +++ b/src/Analyzers/Orchestration/ThreadTaskOrchestrationAnalyzer.cs @@ -29,7 +29,8 @@ public sealed class ThreadTaskOrchestrationAnalyzer : OrchestrationAnalyzer public override ImmutableArray SupportedDiagnostics => [Rule]; diff --git a/src/Client/Core/StartOrchestrationOptionsExtensions.cs b/src/Client/Core/StartOrchestrationOptionsExtensions.cs new file mode 100644 index 000000000..4bfac52fd --- /dev/null +++ b/src/Client/Core/StartOrchestrationOptionsExtensions.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Linq; + +namespace Microsoft.DurableTask.Client; + +/// +/// Extension methods for to provide type-safe deduplication status configuration. +/// +public static class StartOrchestrationOptionsExtensions +{ + public static readonly OrchestrationRuntimeStatus[] ValidDedupeStatuses = new[] + { + OrchestrationRuntimeStatus.Completed, + OrchestrationRuntimeStatus.Failed, + OrchestrationRuntimeStatus.Terminated, + OrchestrationRuntimeStatus.Canceled, + }; + + /// + /// Creates a new with the specified deduplication statuses. + /// + /// The base options to extend. + /// The orchestration runtime statuses that should be considered for deduplication. + /// A new instance with the deduplication statuses set. + public static StartOrchestrationOptions WithDedupeStatuses( + this StartOrchestrationOptions options, + params OrchestrationRuntimeStatus[] dedupeStatuses) + { + return options with + { + DedupeStatuses = dedupeStatuses.Select(s => s.ToString()).ToList(), + }; + } +} diff --git a/src/Client/Grpc/GrpcDurableTaskClient.cs b/src/Client/Grpc/GrpcDurableTaskClient.cs index 766868926..b6a6c72d8 100644 --- a/src/Client/Grpc/GrpcDurableTaskClient.cs +++ b/src/Client/Grpc/GrpcDurableTaskClient.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Immutable; using System.Diagnostics; using System.Text; using DurableTask.Core.History; @@ -91,7 +92,7 @@ public override async Task ScheduleNewOrchestrationInstanceAsync( version = this.options.DefaultVersion; } - var request = new P.CreateInstanceRequest + P.CreateInstanceRequest request = new() { Name = orchestratorName.Name, Version = version, @@ -122,6 +123,34 @@ public override async Task ScheduleNewOrchestrationInstanceAsync( request.ScheduledStartTimestamp = Timestamp.FromDateTimeOffset(startAt.Value.ToUniversalTime()); } + // Set orchestration ID reuse policy for deduplication support + // Note: This requires the protobuf to support OrchestrationIdReusePolicy field + // If the protobuf doesn't support it yet, this will need to be updated when the protobuf is updated + if (options?.DedupeStatuses != null && options.DedupeStatuses.Count > 0) + { + // Parse and validate all status strings to enum first + ImmutableHashSet dedupeStatuses = options.DedupeStatuses + .Select(s => + { + if (!System.Enum.TryParse(s, ignoreCase: true, out OrchestrationRuntimeStatus status)) + { + throw new ArgumentException( + $"Invalid orchestration runtime status: '{s}' for deduplication."); + } + + return status; + }).ToImmutableHashSet(); + + // Convert dedupe statuses to protobuf statuses and create reuse policy + IEnumerable dedupeStatusesProto = dedupeStatuses.Select(s => s.ToGrpcStatus()); + P.OrchestrationIdReusePolicy? policy = ProtoUtils.ConvertDedupeStatusesToReusePolicy(dedupeStatusesProto); + + if (policy != null) + { + request.OrchestrationIdReusePolicy = policy; + } + } + using Activity? newActivity = TraceHelper.StartActivityForNewOrchestration(request); P.CreateInstanceResponse? result = await this.sidecarClient.StartInstanceAsync( @@ -405,7 +434,7 @@ public override async Task RestartAsync( Check.NotNullOrEmpty(instanceId); Check.NotEntity(this.options.EnableEntitySupport, instanceId); - var request = new P.RestartInstanceRequest + P.RestartInstanceRequest request = new P.RestartInstanceRequest { InstanceId = instanceId, RestartWithNewInstanceId = restartWithNewInstanceId, @@ -441,7 +470,7 @@ public override async Task RewindInstanceAsync( Check.NotNullOrEmpty(instanceId); Check.NotEntity(this.options.EnableEntitySupport, instanceId); - var request = new P.RewindInstanceRequest + P.RewindInstanceRequest request = new P.RewindInstanceRequest { InstanceId = instanceId, Reason = reason, @@ -573,7 +602,7 @@ async Task PurgeInstancesCoreAsync( OrchestrationMetadata CreateMetadata(P.OrchestrationState state, bool includeInputsAndOutputs) { - var metadata = new OrchestrationMetadata(state.Name, state.InstanceId) + OrchestrationMetadata metadata = new OrchestrationMetadata(state.Name, state.InstanceId) { CreatedAt = state.CreatedTimestamp.ToDateTimeOffset(), LastUpdatedAt = state.LastUpdatedTimestamp.ToDateTimeOffset(), diff --git a/src/Client/Grpc/ProtoUtils.cs b/src/Client/Grpc/ProtoUtils.cs index f5bc750d7..f307f43fc 100644 --- a/src/Client/Grpc/ProtoUtils.cs +++ b/src/Client/Grpc/ProtoUtils.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Collections.Immutable; +using System.Linq; using P = Microsoft.DurableTask.Protobuf; namespace Microsoft.DurableTask.Client.Grpc; @@ -8,8 +10,84 @@ namespace Microsoft.DurableTask.Client.Grpc; /// /// Protobuf helpers and utilities. /// -static class ProtoUtils +public static class ProtoUtils { + /// + /// Gets the terminal orchestration statuses that are commonly used for deduplication. + /// These are the statuses that can be used in OrchestrationIdReusePolicy. + /// + /// An immutable array of terminal orchestration statuses. + public static ImmutableArray GetTerminalStatuses() + { +#pragma warning disable CS0618 // Type or member is obsolete - Canceled is intentionally included for compatibility + return ImmutableArray.Create( + P.OrchestrationStatus.Completed, + P.OrchestrationStatus.Failed, + P.OrchestrationStatus.Terminated, + P.OrchestrationStatus.Canceled); +#pragma warning restore CS0618 + } + + /// + /// Converts dedupe statuses (statuses that should NOT be replaced) to an OrchestrationIdReusePolicy + /// with replaceable statuses (statuses that CAN be replaced). + /// + /// The orchestration statuses that should NOT be replaced. These are statuses for which an exception should be thrown if an orchestration already exists. + /// An OrchestrationIdReusePolicy with replaceable statuses set, or null if all terminal statuses are dedupe statuses. + /// + /// The policy uses "replaceableStatus" - these are statuses that CAN be replaced. + /// dedupeStatuses are statuses that should NOT be replaced. + /// So replaceableStatus = all terminal statuses MINUS dedupeStatuses. + /// + public static P.OrchestrationIdReusePolicy? ConvertDedupeStatusesToReusePolicy( + IEnumerable? dedupeStatuses) + { + ImmutableArray terminalStatuses = GetTerminalStatuses(); + ImmutableHashSet dedupeStatusSet = dedupeStatuses?.ToImmutableHashSet() ?? ImmutableHashSet.Empty; + + P.OrchestrationIdReusePolicy policy = new(); + + // Add terminal statuses that are NOT in dedupeStatuses as replaceable + foreach (P.OrchestrationStatus terminalStatus in terminalStatuses.Where(status => !dedupeStatusSet.Contains(status))) + { + policy.ReplaceableStatus.Add(terminalStatus); + } + + // Only return policy if we have replaceable statuses + return policy.ReplaceableStatus.Count > 0 ? policy : null; + } + + /// + /// Converts an OrchestrationIdReusePolicy with replaceable statuses to dedupe statuses + /// (statuses that should NOT be replaced). + /// + /// The OrchestrationIdReusePolicy containing replaceable statuses. + /// An array of orchestration statuses that should NOT be replaced, or null if all terminal statuses are replaceable. + /// + /// The policy uses "replaceableStatus" - these are statuses that CAN be replaced. + /// dedupeStatuses are statuses that should NOT be replaced (should throw exception). + /// So dedupeStatuses = all terminal statuses MINUS replaceableStatus. + /// + public static P.OrchestrationStatus[]? ConvertReusePolicyToDedupeStatuses( + P.OrchestrationIdReusePolicy? policy) + { + if (policy == null || policy.ReplaceableStatus.Count == 0) + { + return null; + } + + ImmutableArray terminalStatuses = GetTerminalStatuses(); + ImmutableHashSet replaceableStatusSet = policy.ReplaceableStatus.ToImmutableHashSet(); + + // Calculate dedupe statuses = terminal statuses - replaceable statuses + P.OrchestrationStatus[] dedupeStatuses = terminalStatuses + .Where(terminalStatus => !replaceableStatusSet.Contains(terminalStatus)) + .ToArray(); + + // Only return if there are dedupe statuses + return dedupeStatuses.Length > 0 ? dedupeStatuses : null; + } + #pragma warning disable 0618 // Referencing Obsolete member. This is intention as we are only converting it. /// /// Converts to . diff --git a/src/Client/OrchestrationServiceClientShim/ShimDurableTaskClient.cs b/src/Client/OrchestrationServiceClientShim/ShimDurableTaskClient.cs index 4fbf828d0..f6b6140f1 100644 --- a/src/Client/OrchestrationServiceClientShim/ShimDurableTaskClient.cs +++ b/src/Client/OrchestrationServiceClientShim/ShimDurableTaskClient.cs @@ -7,6 +7,7 @@ using DurableTask.Core; using DurableTask.Core.History; using DurableTask.Core.Query; +using Microsoft.DurableTask.Client; using Microsoft.DurableTask.Client.Entities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -192,7 +193,23 @@ public override async Task ScheduleNewOrchestrationInstanceAsync( }, }; - await this.Client.CreateTaskOrchestrationAsync(message); + Core.OrchestrationStatus[]? dedupeStatuses = null; + if (options?.DedupeStatuses != null && options.DedupeStatuses.Count > 0) + { + dedupeStatuses = options.DedupeStatuses + .Select(s => + { + if (!Enum.TryParse(s, ignoreCase: true, out var status)) + { + throw new ArgumentException( + $"Invalid orchestration runtime status: '{s}' for deduplication."); + } + return status.ConvertToCore(); + }) + .ToArray(); + } + + await this.Client.CreateTaskOrchestrationAsync(message, dedupeStatuses); return instanceId; } @@ -303,7 +320,7 @@ public override async Task RestartAsync( }, }; - await this.Client.CreateTaskOrchestrationAsync(message); + await this.Client.CreateTaskOrchestrationAsync(message, dedupeStatuses: null); return newInstanceId; } diff --git a/src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs b/src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs index 9edced26d..ec66a44bd 100644 --- a/src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs +++ b/src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs @@ -2,7 +2,9 @@ // Licensed under the MIT License. using System.IO.Compression; +using System.Net; using System.Text; +using Azure; using Azure.Core; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; @@ -117,20 +119,30 @@ public override async Task DownloadAsync(string token, CancellationToken BlobClient blob = this.containerClient.GetBlobClient(name); - using BlobDownloadStreamingResult result = await blob.DownloadStreamingAsync(cancellationToken: cancellationToken); - Stream contentStream = result.Content; - bool isGzip = string.Equals( - result.Details.ContentEncoding, ContentEncodingGzip, StringComparison.OrdinalIgnoreCase); + try + { + using BlobDownloadStreamingResult result = await blob.DownloadStreamingAsync(cancellationToken: cancellationToken); + Stream contentStream = result.Content; + bool isGzip = string.Equals( + result.Details.ContentEncoding, ContentEncodingGzip, StringComparison.OrdinalIgnoreCase); + + if (isGzip) + { + using GZipStream decompressed = new(contentStream, CompressionMode.Decompress); + using StreamReader reader = new(decompressed, Encoding.UTF8); + return await ReadToEndAsync(reader, cancellationToken); + } - if (isGzip) + using StreamReader uncompressedReader = new(contentStream, Encoding.UTF8); + return await ReadToEndAsync(uncompressedReader, cancellationToken); + } + catch (RequestFailedException ex) when (ex.Status == (int)HttpStatusCode.NotFound) { - using GZipStream decompressed = new(contentStream, CompressionMode.Decompress); - using StreamReader reader = new(decompressed, Encoding.UTF8); - return await ReadToEndAsync(reader, cancellationToken); + throw new InvalidOperationException( + $"The blob '{name}' was not found in container '{container}'. " + + "The payload may have been deleted or the container was never created.", + ex); } - - using StreamReader uncompressedReader = new(contentStream, Encoding.UTF8); - return await ReadToEndAsync(uncompressedReader, cancellationToken); } /// diff --git a/src/Grpc/orchestrator_service.proto b/src/Grpc/orchestrator_service.proto index 1a86c0a27..196c88da6 100644 --- a/src/Grpc/orchestrator_service.proto +++ b/src/Grpc/orchestrator_service.proto @@ -276,6 +276,7 @@ message CreateSubOrchestrationAction { google.protobuf.StringValue version = 3; google.protobuf.StringValue input = 4; TraceContext parentTraceContext = 5; + map tags = 6; } message CreateTimerAction { @@ -356,6 +357,13 @@ message OrchestratorResponse { // Whether or not a history is required to complete the original OrchestratorRequest and none was provided. bool requiresHistory = 7; + + // 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; + + // 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; } message CreateInstanceRequest { diff --git a/src/Grpc/versions.txt b/src/Grpc/versions.txt index 69b075cd3..3b7d51216 100644 --- a/src/Grpc/versions.txt +++ b/src/Grpc/versions.txt @@ -1,2 +1,2 @@ -# The following files were downloaded from branch main at 2025-11-14 16:36:47 UTC -https://raw.githubusercontent.com/microsoft/durabletask-protobuf/9f762f1301b91e3e7c736b9c5a29c2e09f2a850e/protos/orchestrator_service.proto +# The following files were downloaded from branch main at 2025-12-12 01:49:06 UTC +https://raw.githubusercontent.com/microsoft/durabletask-protobuf/b03c06dea21952dcf2a86551fd761b6b78c64d15/protos/orchestrator_service.proto diff --git a/src/InProcessTestHost/Sidecar/Grpc/ProtobufUtils.cs b/src/InProcessTestHost/Sidecar/Grpc/ProtobufUtils.cs index fe4e093cb..8289574b6 100644 --- a/src/InProcessTestHost/Sidecar/Grpc/ProtobufUtils.cs +++ b/src/InProcessTestHost/Sidecar/Grpc/ProtobufUtils.cs @@ -288,7 +288,7 @@ public static OrchestratorAction ToOrchestratorAction(Proto.OrchestratorAction a ParentTraceContext = a.CreateSubOrchestration.ParentTraceContext is not null ? new DistributedTraceContext(a.CreateSubOrchestration.ParentTraceContext.TraceParent, a.CreateSubOrchestration.ParentTraceContext.TraceState) : null, - Tags = null, // TODO + Tags = a.CreateSubOrchestration.Tags, Version = a.CreateSubOrchestration.Version, }; case Proto.OrchestratorAction.OrchestratorActionTypeOneofCase.CreateTimer: diff --git a/src/InProcessTestHost/Sidecar/Grpc/TaskHubGrpcServer.cs b/src/InProcessTestHost/Sidecar/Grpc/TaskHubGrpcServer.cs index 15f65dd84..bbb162c3c 100644 --- a/src/InProcessTestHost/Sidecar/Grpc/TaskHubGrpcServer.cs +++ b/src/InProcessTestHost/Sidecar/Grpc/TaskHubGrpcServer.cs @@ -1,14 +1,17 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using System.Collections.Concurrent; using System.Diagnostics; using System.Globalization; +using System.Linq; using DurableTask.Core; +using DurableTask.Core.Exceptions; using DurableTask.Core.History; using DurableTask.Core.Query; using Google.Protobuf.WellKnownTypes; using Grpc.Core; +using Microsoft.DurableTask.Client.Grpc; using Microsoft.DurableTask.Testing.Sidecar.Dispatcher; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -202,6 +205,18 @@ async Task WaitForWorkItemClientConnection() try { + // Convert OrchestrationIdReusePolicy to dedupeStatuses + // The policy uses "replaceableStatus" - these are statuses that CAN be replaced + // dedupeStatuses are statuses that should NOT be replaced (should throw exception) + // So dedupeStatuses = all terminal statuses MINUS replaceableStatus + OrchestrationStatus[]? dedupeStatuses = null; + P.OrchestrationStatus[]? dedupeStatusesProto = ProtoUtils.ConvertReusePolicyToDedupeStatuses(request.OrchestrationIdReusePolicy); + if (dedupeStatusesProto != null) + { + // Convert protobuf statuses to Core.OrchestrationStatus + dedupeStatuses = dedupeStatusesProto.Select(s => (OrchestrationStatus)s).ToArray(); + } + await this.client.CreateTaskOrchestrationAsync( new TaskMessage { @@ -216,7 +231,14 @@ await this.client.CreateTaskOrchestrationAsync( : null }, OrchestrationInstance = instance, - }); + }, + dedupeStatuses); + } + catch (OrchestrationAlreadyExistsException e) + { + // Convert to gRPC exception + this.log.LogWarning(e, "Orchestration with ID {InstanceId} already exists", instance.InstanceId); + throw new RpcException(new Status(StatusCode.AlreadyExists, e.Message)); } catch (Exception e) { diff --git a/src/InProcessTestHost/Sidecar/InMemoryOrchestrationService.cs b/src/InProcessTestHost/Sidecar/InMemoryOrchestrationService.cs index eb8fd0424..40691bd91 100644 --- a/src/InProcessTestHost/Sidecar/InMemoryOrchestrationService.cs +++ b/src/InProcessTestHost/Sidecar/InMemoryOrchestrationService.cs @@ -739,6 +739,12 @@ public void ReleaseLock(string instanceId) public Task WaitForInstanceAsync(string instanceId, CancellationToken cancellationToken) { + // First, add the waiter before checking completion to avoid a race condition. + // This ensures we don't miss a completion notification that happens between + // checking the status and adding the waiter. + var tcs = this.waiters.GetOrAdd(instanceId, _ => new TaskCompletionSource()); + + // Now check if already completed - if so, complete the waiter immediately if (this.store.TryGetValue(instanceId, out SerializedInstanceState? state)) { lock (state) @@ -750,8 +756,11 @@ public Task WaitForInstanceAsync(string instanceId, Cancella statusRecord.OrchestrationStatus == OrchestrationStatus.Failed || statusRecord.OrchestrationStatus == OrchestrationStatus.Terminated) { - // orchestration has already completed - return Task.FromResult(statusRecord); + // Orchestration has already completed - complete the waiter and clean it up + if (tcs.TrySetResult(statusRecord)) + { + this.waiters.TryRemove(instanceId, out _); + } } } } @@ -759,7 +768,6 @@ public Task WaitForInstanceAsync(string instanceId, Cancella // Caller will be notified when the instance completes. // The ContinueWith is just to enable cancellation: https://stackoverflow.com/a/25652873/2069 - var tcs = this.waiters.GetOrAdd(instanceId, _ => new TaskCompletionSource()); return tcs.Task.ContinueWith(t => t.GetAwaiter().GetResult(), cancellationToken); } diff --git a/src/Shared/Grpc/ProtoUtils.cs b/src/Shared/Grpc/ProtoUtils.cs index ab5551d45..2412fac37 100644 --- a/src/Shared/Grpc/ProtoUtils.cs +++ b/src/Shared/Grpc/ProtoUtils.cs @@ -373,7 +373,16 @@ internal static P.OrchestratorResponse ConstructOrchestratorResponse( Name = subOrchestrationAction.Name, Version = subOrchestrationAction.Version, ParentTraceContext = CreateTraceContext(), - }; + }; + + if (subOrchestrationAction.Tags != null) + { + foreach (KeyValuePair tag in subOrchestrationAction.Tags) + { + protoAction.CreateSubOrchestration.Tags[tag.Key] = tag.Value; + } + } + break; case OrchestratorActionType.CreateTimer: var createTimerAction = (CreateTimerOrchestratorAction)action; diff --git a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs index 44430610e..945d6ac5b 100644 --- a/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs +++ b/src/Worker/Core/Shims/TaskOrchestrationContextWrapper.cs @@ -235,7 +235,8 @@ public override async Task CallSubOrchestratorAsync( orchestratorName.Name, version, instanceId, - input), + input, + options?.Tags), orchestratorName.Name, handler, default); @@ -246,7 +247,8 @@ public override async Task CallSubOrchestratorAsync( orchestratorName.Name, version, instanceId, - input); + input, + options?.Tags); } } catch (global::DurableTask.Core.Exceptions.SubOrchestrationFailedException e) diff --git a/src/Worker/Grpc/Worker.Grpc.csproj b/src/Worker/Grpc/Worker.Grpc.csproj index 52ea39547..ed83f39a2 100644 --- a/src/Worker/Grpc/Worker.Grpc.csproj +++ b/src/Worker/Grpc/Worker.Grpc.csproj @@ -13,6 +13,7 @@ + diff --git a/test/Abstractions.Tests/Abstractions.Tests.csproj b/test/Abstractions.Tests/Abstractions.Tests.csproj index 9e3b1ad51..665f53f64 100644 --- a/test/Abstractions.Tests/Abstractions.Tests.csproj +++ b/test/Abstractions.Tests/Abstractions.Tests.csproj @@ -6,6 +6,7 @@ + diff --git a/test/Abstractions.Tests/TaskOptionsTests.cs b/test/Abstractions.Tests/TaskOptionsTests.cs index fe4a101e0..3e805a9ef 100644 --- a/test/Abstractions.Tests/TaskOptionsTests.cs +++ b/test/Abstractions.Tests/TaskOptionsTests.cs @@ -1,13 +1,15 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Microsoft.DurableTask.Tests; - -public class TaskOptionsTests -{ - [Fact] - public void Empty_Ctors_Okay() - { +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask.Client; + +namespace Microsoft.DurableTask.Tests; + +public class TaskOptionsTests +{ + [Fact] + public void Empty_Ctors_Okay() + { TaskOptions options = new(); options.Retry.Should().BeNull(); options.Tags.Should().BeNull(); @@ -21,21 +23,129 @@ public void Empty_Ctors_Okay() startOptions.Version.Should().BeNull(); startOptions.InstanceId.Should().BeNull(); startOptions.StartAt.Should().BeNull(); - startOptions.Tags.Should().BeEmpty(); + startOptions.Tags.Should().BeEmpty(); } - [Fact] - public void SubOrchestrationOptions_InstanceId_Correct() + [Fact] + public void SubOrchestrationOptions_InstanceId_Correct() { string instanceId = Guid.NewGuid().ToString(); SubOrchestrationOptions subOptions = new(new TaskOptions(), instanceId); instanceId.Equals(subOptions.InstanceId).Should().BeTrue(); string subInstanceId = Guid.NewGuid().ToString(); - subOptions = new(new SubOrchestrationOptions(instanceId: subInstanceId)); + subOptions = new(new SubOrchestrationOptions(instanceId: subInstanceId)); subInstanceId.Equals(subOptions.InstanceId).Should().BeTrue(); - subOptions = new(new SubOrchestrationOptions(instanceId: subInstanceId), instanceId); + subOptions = new(new SubOrchestrationOptions(instanceId: subInstanceId), instanceId); instanceId.Equals(subOptions.InstanceId).Should().BeTrue(); - } -} + } + + [Fact] + public void WithDedupeStatuses_SetsCorrectStringValues() + { + // Arrange + StartOrchestrationOptions options = new(); + OrchestrationRuntimeStatus[] statuses = new[] + { + OrchestrationRuntimeStatus.Completed, + OrchestrationRuntimeStatus.Failed, + OrchestrationRuntimeStatus.Terminated, + }; + + // Act + StartOrchestrationOptions result = options.WithDedupeStatuses(statuses); + + // Assert + result.DedupeStatuses.Should().NotBeNull(); + result.DedupeStatuses.Should().HaveCount(3); + result.DedupeStatuses.Should().Contain("Completed"); + result.DedupeStatuses.Should().Contain("Failed"); + result.DedupeStatuses.Should().Contain("Terminated"); + } + + [Fact] + public void WithDedupeStatuses_HandlesEmptyArray() + { + // Arrange + StartOrchestrationOptions options = new(); + + // Act + StartOrchestrationOptions result = options.WithDedupeStatuses(); + + // Assert + result.DedupeStatuses.Should().NotBeNull(); + result.DedupeStatuses.Should().BeEmpty(); + } + + [Fact] + public void WithDedupeStatuses_HandlesEmptyArrayExplicit() + { + // Arrange + StartOrchestrationOptions options = new(); + OrchestrationRuntimeStatus[] statuses = Array.Empty(); + + // Act + StartOrchestrationOptions result = options.WithDedupeStatuses(statuses); + + // Assert + result.DedupeStatuses.Should().NotBeNull(); + result.DedupeStatuses.Should().BeEmpty(); + } + + [Fact] + public void WithDedupeStatuses_PreservesOtherProperties() + { + // Arrange + string instanceId = Guid.NewGuid().ToString(); + DateTimeOffset startAt = DateTimeOffset.UtcNow.AddHours(1); + StartOrchestrationOptions options = new(instanceId, startAt); + + // Act + StartOrchestrationOptions result = options.WithDedupeStatuses( + OrchestrationRuntimeStatus.Completed, + OrchestrationRuntimeStatus.Failed); + + // Assert + result.InstanceId.Should().Be(instanceId); + result.StartAt.Should().Be(startAt); + result.DedupeStatuses.Should().NotBeNull(); + result.DedupeStatuses.Should().HaveCount(2); + } + + [Fact] + public void ValidDedupeStatuses_ContainsExpectedTerminalStatuses() + { + // Act +#pragma warning disable CS0618 // Type or member is obsolete - Canceled is intentionally included for compatibility + OrchestrationRuntimeStatus[] validStatuses = StartOrchestrationOptionsExtensions.ValidDedupeStatuses; + + // Assert + validStatuses.Should().NotBeNull(); + validStatuses.Should().HaveCount(4); + validStatuses.Should().Contain(OrchestrationRuntimeStatus.Completed); + validStatuses.Should().Contain(OrchestrationRuntimeStatus.Failed); + validStatuses.Should().Contain(OrchestrationRuntimeStatus.Terminated); + validStatuses.Should().Contain(OrchestrationRuntimeStatus.Canceled); +#pragma warning restore CS0618 + } + + [Fact] + public void WithDedupeStatuses_ConvertsAllEnumValuesToStrings() + { + // Arrange + StartOrchestrationOptions options = new(); + OrchestrationRuntimeStatus[] allStatuses = Enum.GetValues(); + + // Act + StartOrchestrationOptions result = options.WithDedupeStatuses(allStatuses); + + // Assert + result.DedupeStatuses.Should().NotBeNull(); + result.DedupeStatuses.Should().HaveCount(allStatuses.Length); + foreach (OrchestrationRuntimeStatus status in allStatuses) + { + result.DedupeStatuses.Should().Contain(status.ToString()); + } + } +} diff --git a/test/Analyzers.Tests/DiagnosticDescriptorTests.cs b/test/Analyzers.Tests/DiagnosticDescriptorTests.cs new file mode 100644 index 000000000..0ef564ac7 --- /dev/null +++ b/test/Analyzers.Tests/DiagnosticDescriptorTests.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using System.Reflection; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.DurableTask.Analyzers.Activities; +using Microsoft.DurableTask.Analyzers.Functions.AttributeBinding; +using Microsoft.DurableTask.Analyzers.Functions.Orchestration; +using Microsoft.DurableTask.Analyzers.Orchestration; + +namespace Microsoft.DurableTask.Analyzers.Tests; + +/// +/// Tests to validate diagnostic descriptor properties. +/// +public class DiagnosticDescriptorTests +{ + [Theory] + [InlineData(typeof(DateTimeOrchestrationAnalyzer))] + [InlineData(typeof(GuidOrchestrationAnalyzer))] + [InlineData(typeof(DelayOrchestrationAnalyzer))] + [InlineData(typeof(ThreadTaskOrchestrationAnalyzer))] + [InlineData(typeof(IOOrchestrationAnalyzer))] + [InlineData(typeof(EnvironmentOrchestrationAnalyzer))] + [InlineData(typeof(CancellationTokenOrchestrationAnalyzer))] + [InlineData(typeof(OtherBindingsOrchestrationAnalyzer))] + [InlineData(typeof(OrchestrationTriggerBindingAnalyzer))] + [InlineData(typeof(DurableClientBindingAnalyzer))] + [InlineData(typeof(EntityTriggerBindingAnalyzer))] + [InlineData(typeof(MatchingInputOutputTypeActivityAnalyzer))] + [InlineData(typeof(FunctionNotFoundAnalyzer))] + public void AllDiagnosticDescriptorsHaveHelpLinkUri(Type analyzerType) + { + // Arrange + DiagnosticAnalyzer? analyzer = Activator.CreateInstance(analyzerType) as DiagnosticAnalyzer; + Assert.NotNull(analyzer); + + ImmutableArray diagnostics = analyzer!.SupportedDiagnostics; + Assert.NotEmpty(diagnostics); + + // Act & Assert + foreach (Microsoft.CodeAnalysis.DiagnosticDescriptor diagnostic in diagnostics) + { + Assert.NotNull(diagnostic.HelpLinkUri); + Assert.NotEmpty(diagnostic.HelpLinkUri); + Assert.Equal("https://go.microsoft.com/fwlink/?linkid=2346202", diagnostic.HelpLinkUri); + } + } +} diff --git a/test/Analyzers.Tests/Orchestration/DateTimeOrchestrationAnalyzerTests.cs b/test/Analyzers.Tests/Orchestration/DateTimeOrchestrationAnalyzerTests.cs index 0f5fd842c..4404dff85 100644 --- a/test/Analyzers.Tests/Orchestration/DateTimeOrchestrationAnalyzerTests.cs +++ b/test/Analyzers.Tests/Orchestration/DateTimeOrchestrationAnalyzerTests.cs @@ -377,6 +377,134 @@ await VerifyCS.VerifyDurableTaskCodeFixAsync(code, expected, fix, test => } + [Theory] + [InlineData("DateTimeOffset.Now")] + [InlineData("DateTimeOffset.UtcNow")] + public async Task DurableFunctionOrchestrationUsingDateTimeOffsetNonDeterministicPropertiesHasDiag(string expression) + { + string code = Wrapper.WrapDurableFunctionOrchestration($@" +[Function(""Run"")] +DateTimeOffset Run([OrchestrationTrigger] TaskOrchestrationContext context) +{{ + return {{|#0:{expression}|}}; +}} +"); + + string fix = Wrapper.WrapDurableFunctionOrchestration($@" +[Function(""Run"")] +DateTimeOffset Run([OrchestrationTrigger] TaskOrchestrationContext context) +{{ + return (DateTimeOffset)context.CurrentUtcDateTime; +}} +"); + + DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("Run", $"System.{expression}", "Run"); + + await VerifyCS.VerifyDurableTaskCodeFixAsync(code, expected, fix); + } + + [Fact] + public async Task TaskOrchestratorUsingDateTimeOffsetHasDiag() + { + string code = Wrapper.WrapTaskOrchestrator(@" +public class MyOrchestrator : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, string input) + { + return Task.FromResult({|#0:DateTimeOffset.Now|}); + } +} +"); + + string fix = Wrapper.WrapTaskOrchestrator(@" +public class MyOrchestrator : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, string input) + { + return Task.FromResult((DateTimeOffset)context.CurrentUtcDateTime); + } +} +"); + + DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("RunAsync", "System.DateTimeOffset.Now", "MyOrchestrator"); + + await VerifyCS.VerifyDurableTaskCodeFixAsync(code, expected, fix); + } + + [Fact] + public async Task FuncOrchestratorWithDateTimeOffsetHasDiag() + { + string code = Wrapper.WrapFuncOrchestrator(@" +tasks.AddOrchestratorFunc(""HelloSequence"", context => +{ + return {|#0:DateTimeOffset.UtcNow|}; +}); +"); + + string fix = Wrapper.WrapFuncOrchestrator(@" +tasks.AddOrchestratorFunc(""HelloSequence"", context => +{ + return (DateTimeOffset)context.CurrentUtcDateTime; +}); +"); + + DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("Main", "System.DateTimeOffset.UtcNow", "HelloSequence"); + + await VerifyCS.VerifyDurableTaskCodeFixAsync(code, expected, fix); + } + + [Fact] + public async Task TaskOrchestratorSdkOnlyHasDiag() + { + // Tests that the analyzer works with SDK-only references (without Azure Functions assemblies) + string code = Wrapper.WrapTaskOrchestratorSdkOnly(@" +public class MyOrchestrator : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, string input) + { + return Task.FromResult({|#0:DateTime.Now|}); + } +} +"); + + string fix = Wrapper.WrapTaskOrchestratorSdkOnly(@" +public class MyOrchestrator : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, string input) + { + return Task.FromResult(context.CurrentUtcDateTime); + } +} +"); + + DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("RunAsync", "System.DateTime.Now", "MyOrchestrator"); + + await VerifyCS.VerifySdkOnlyCodeFixAsync(code, expected, fix); + } + + [Fact] + public async Task FuncOrchestratorSdkOnlyWithLambdaHasDiag() + { + // Tests that the analyzer works with SDK-only references (without Azure Functions assemblies) + string code = Wrapper.WrapFuncOrchestratorSdkOnly(@" +tasks.AddOrchestratorFunc(""HelloSequence"", context => +{ + return {|#0:DateTime.Now|}; +}); +"); + + string fix = Wrapper.WrapFuncOrchestratorSdkOnly(@" +tasks.AddOrchestratorFunc(""HelloSequence"", context => +{ + return context.CurrentUtcDateTime; +}); +"); + + DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("Main", "System.DateTime.Now", "HelloSequence"); + + await VerifyCS.VerifySdkOnlyCodeFixAsync(code, expected, fix); + } + static DiagnosticResult BuildDiagnostic() { return VerifyCS.Diagnostic(DateTimeOrchestrationAnalyzer.DiagnosticId); diff --git a/test/Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.Durable.cs b/test/Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.Durable.cs index 00253ef68..a68d8b5d1 100644 --- a/test/Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.Durable.cs +++ b/test/Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.Durable.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using Microsoft.CodeAnalysis.Diagnostics; @@ -30,4 +30,23 @@ public static async Task VerifyDurableTaskAnalyzerAsync(string source, Action + /// Runs analyzer test with SDK-only references (without Azure Functions assemblies). + /// Used to test orchestration detection in non-function scenarios. + /// + public static async Task VerifySdkOnlyAnalyzerAsync(string source, Action? configureTest = null, params DiagnosticResult[] expected) + { + Test test = new() + { + TestCode = source, + ReferenceAssemblies = References.SdkOnlyAssemblies, + }; + + test.ExpectedDiagnostics.AddRange(expected); + + configureTest?.Invoke(test); + + await test.RunAsync(CancellationToken.None); + } } diff --git a/test/Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.Durable.cs b/test/Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.Durable.cs index 139ee3c53..b69fae93b 100644 --- a/test/Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.Durable.cs +++ b/test/Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.Durable.cs @@ -1,4 +1,7 @@ -using Microsoft.CodeAnalysis.CodeFixes; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Testing; @@ -19,7 +22,7 @@ public static async Task VerifyDurableTaskAnalyzerAsync( await RunAsync(expected, new Test() { TestCode = source, - }, configureTest); + }, References.CommonAssemblies, configureTest); } public static Task VerifyDurableTaskCodeFixAsync( @@ -36,12 +39,59 @@ public static async Task VerifyDurableTaskCodeFixAsync( TestCode = source, FixedCode = fixedSource, }, - configureTest); + References.CommonAssemblies, configureTest); + } + + /// + /// Runs analyzer test with SDK-only references (without Azure Functions assemblies). + /// Used to test orchestration detection in non-function scenarios. + /// + public static Task VerifySdkOnlyAnalyzerAsync(string source, params DiagnosticResult[] expected) + { + return VerifySdkOnlyAnalyzerAsync(source, null, expected); + } + + /// + /// Runs analyzer test with SDK-only references (without Azure Functions assemblies). + /// Used to test orchestration detection in non-function scenarios. + /// + public static async Task VerifySdkOnlyAnalyzerAsync( + string source, Action? configureTest = null, params DiagnosticResult[] expected) + { + await RunAsync(expected, new Test() + { + TestCode = source, + }, References.SdkOnlyAssemblies, configureTest); + } + + /// + /// Runs code fix test with SDK-only references (without Azure Functions assemblies). + /// Used to test orchestration detection in non-function scenarios. + /// + public static Task VerifySdkOnlyCodeFixAsync( + string source, DiagnosticResult expected, string fixedSource, Action? configureTest = null) + { + return VerifySdkOnlyCodeFixAsync(source, [expected], fixedSource, configureTest); + } + + /// + /// Runs code fix test with SDK-only references (without Azure Functions assemblies). + /// Used to test orchestration detection in non-function scenarios. + /// + public static async Task VerifySdkOnlyCodeFixAsync( + string source, DiagnosticResult[] expected, string fixedSource, Action? configureTest = null) + { + await RunAsync(expected, new Test() + { + TestCode = source, + FixedCode = fixedSource, + }, + References.SdkOnlyAssemblies, configureTest); } - static async Task RunAsync(DiagnosticResult[] expected, Test test, Action? configureTest = null) + static async Task RunAsync(DiagnosticResult[] expected, Test test, ReferenceAssemblies referenceAssemblies, Action? configureTest = null) { - test.ReferenceAssemblies = References.CommonAssemblies; + test.ReferenceAssemblies = referenceAssemblies; test.ExpectedDiagnostics.AddRange(expected); configureTest?.Invoke(test); diff --git a/test/Analyzers.Tests/Verifiers/References.cs b/test/Analyzers.Tests/Verifiers/References.cs index 1df31e762..59de0ddae 100644 --- a/test/Analyzers.Tests/Verifiers/References.cs +++ b/test/Analyzers.Tests/Verifiers/References.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using Microsoft.CodeAnalysis.Testing; @@ -8,9 +8,16 @@ namespace Microsoft.DurableTask.Analyzers.Tests.Verifiers; public static class References { static readonly Lazy durableAssemblyReferences = new(() => BuildReferenceAssemblies()); + static readonly Lazy durableSdkOnlyReferences = new(() => BuildSdkOnlyReferenceAssemblies()); public static ReferenceAssemblies CommonAssemblies => durableAssemblyReferences.Value; + /// + /// Gets assembly references for non-function SDK tests (without Azure Functions assemblies). + /// Used to test orchestration detection in non-function scenarios. + /// + public static ReferenceAssemblies SdkOnlyAssemblies => durableSdkOnlyReferences.Value; + static ReferenceAssemblies BuildReferenceAssemblies() => ReferenceAssemblies.Net.Net60.AddPackages([ new PackageIdentity("Azure.Storage.Blobs", "12.17.0"), new PackageIdentity("Azure.Storage.Queues", "12.17.0"), @@ -20,4 +27,14 @@ static ReferenceAssemblies BuildReferenceAssemblies() => ReferenceAssemblies.Net new PackageIdentity("Microsoft.Azure.Functions.Worker.Extensions.DurableTask", "1.1.1"), new PackageIdentity("Microsoft.Data.SqlClient", "5.2.0"), ]); + + static ReferenceAssemblies BuildSdkOnlyReferenceAssemblies() => ReferenceAssemblies.Net.Net60.AddPackages([ + new PackageIdentity("Azure.Storage.Blobs", "12.17.0"), + new PackageIdentity("Azure.Storage.Queues", "12.17.0"), + new PackageIdentity("Azure.Data.Tables", "12.8.3"), + new PackageIdentity("Microsoft.Azure.Cosmos", "3.39.1"), + new PackageIdentity("Microsoft.Data.SqlClient", "5.2.0"), + new PackageIdentity("Microsoft.DurableTask.Abstractions", "1.3.0"), + new PackageIdentity("Microsoft.DurableTask.Worker", "1.3.0"), + ]); } diff --git a/test/Analyzers.Tests/Wrapper.cs b/test/Analyzers.Tests/Wrapper.cs index fb5df8708..a3b365230 100644 --- a/test/Analyzers.Tests/Wrapper.cs +++ b/test/Analyzers.Tests/Wrapper.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. namespace Microsoft.DurableTask.Analyzers.Tests; @@ -29,6 +29,43 @@ public static string WrapFuncOrchestrator(string code) return $@" {Usings()} +public class Program +{{ + public static void Main() + {{ + new ServiceCollection().AddDurableTaskWorker(builder => + {{ + builder.AddTasks(tasks => + {{ + {code} + }}); + }}); + }} +}} +"; + } + + /// + /// Wraps code for TaskOrchestrator tests without Azure Functions dependencies. + /// Used for SDK-only testing scenarios. + /// + public static string WrapTaskOrchestratorSdkOnly(string code) + { + return $@" +{UsingsForSdkOnly()} +{code} +"; + } + + /// + /// Wraps code for FuncOrchestrator tests without Azure Functions dependencies. + /// Used for SDK-only testing scenarios. + /// + public static string WrapFuncOrchestratorSdkOnly(string code) + { + return $@" +{UsingsForSdkOnly()} + public class Program {{ public static void Main() @@ -62,6 +99,24 @@ static string Usings() using Microsoft.DurableTask.Client; using Microsoft.DurableTask.Worker; using Microsoft.Extensions.DependencyInjection; +"; + } + + static string UsingsForSdkOnly() + { + return $@" +using Azure.Storage.Blobs; +using Azure.Storage.Queues; +using Azure.Data.Tables; +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.Cosmos; +using Microsoft.Data.SqlClient; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Worker; +using Microsoft.Extensions.DependencyInjection; "; } } diff --git a/test/AzureFunctionsSmokeTests/AzureFunctionsSmokeTests.csproj b/test/AzureFunctionsSmokeTests/AzureFunctionsSmokeTests.csproj new file mode 100644 index 000000000..219d5bc98 --- /dev/null +++ b/test/AzureFunctionsSmokeTests/AzureFunctionsSmokeTests.csproj @@ -0,0 +1,46 @@ + + + + net8.0 + v4 + Exe + enable + + false + false + + false + false + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + diff --git a/test/AzureFunctionsSmokeTests/Dockerfile b/test/AzureFunctionsSmokeTests/Dockerfile new file mode 100644 index 000000000..4df636799 --- /dev/null +++ b/test/AzureFunctionsSmokeTests/Dockerfile @@ -0,0 +1,10 @@ +# Use the Azure Functions base image for .NET 8.0 isolated +FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0 + +# Set environment variables +ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ + AzureFunctionsJobHost__Logging__Console__IsEnabled=true \ + FUNCTIONS_WORKER_RUNTIME=dotnet-isolated + +# Copy the published app +COPY ./publish /home/site/wwwroot diff --git a/test/AzureFunctionsSmokeTests/HelloCitiesOrchestration.cs b/test/AzureFunctionsSmokeTests/HelloCitiesOrchestration.cs new file mode 100644 index 000000000..14a5b5f53 --- /dev/null +++ b/test/AzureFunctionsSmokeTests/HelloCitiesOrchestration.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.Extensions.Logging; + +namespace AzureFunctionsSmokeTests; + +/// +/// Smoke test orchestration functions for Azure Functions with Durable Task. +/// +public static class HelloCitiesOrchestration +{ + [Function(nameof(HelloCitiesOrchestration))] + public static async Task> RunOrchestrator( + [OrchestrationTrigger] TaskOrchestrationContext context) + { + ILogger logger = context.CreateReplaySafeLogger(nameof(HelloCitiesOrchestration)); + logger.LogInformation("Starting HelloCities orchestration."); + + List outputs = new List(); + + // Call activities in sequence + outputs.Add(await context.CallActivityAsync(nameof(SayHello), "Tokyo")); + outputs.Add(await context.CallActivityAsync(nameof(SayHello), "Seattle")); + outputs.Add(await context.CallActivityAsync(nameof(SayHello), "London")); + + logger.LogInformation("HelloCities orchestration completed."); + + // returns ["Hello Tokyo!", "Hello Seattle!", "Hello London!"] + return outputs; + } + + [Function(nameof(SayHello))] + public static string SayHello([ActivityTrigger] string name, FunctionContext executionContext) + { + ILogger logger = executionContext.GetLogger(nameof(SayHello)); + logger.LogInformation($"Saying hello to {name}."); + return $"Hello {name}!"; + } + + [Function("HelloCitiesOrchestration_HttpStart")] + public static async Task HttpStart( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req, + [DurableClient] DurableTaskClient client, + FunctionContext executionContext) + { + ILogger logger = executionContext.GetLogger("HelloCitiesOrchestration_HttpStart"); + + // Function input comes from the request content. + string instanceId = await client + .ScheduleNewOrchestrationInstanceAsync(nameof(HelloCitiesOrchestration)); + + logger.LogInformation($"Started orchestration with ID = '{instanceId}'."); + + // Returns an HTTP 202 response with an instance management payload. + return client.CreateCheckStatusResponse(req, instanceId); + } +} diff --git a/test/AzureFunctionsSmokeTests/Program.cs b/test/AzureFunctionsSmokeTests/Program.cs new file mode 100644 index 000000000..eddb3547e --- /dev/null +++ b/test/AzureFunctionsSmokeTests/Program.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Hosting; + +namespace AzureFunctionsSmokeTests; + +public class Program +{ + public static void Main() + { + IHost host = new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .Build(); + + host.Run(); + } +} diff --git a/test/AzureFunctionsSmokeTests/README.md b/test/AzureFunctionsSmokeTests/README.md new file mode 100644 index 000000000..bfdf4241c --- /dev/null +++ b/test/AzureFunctionsSmokeTests/README.md @@ -0,0 +1,83 @@ +# Azure Functions Smoke Tests + +This directory contains smoke tests for Azure Functions with Durable Task, designed to validate the SDK and Source Generator functionality in a real Azure Functions isolated .NET environment. + +## Overview + +The smoke tests ensure that: +- The Durable Task SDK works correctly with Azure Functions isolated worker +- Source generators produce valid code +- Orchestrations can be triggered and completed successfully +- The complete end-to-end workflow functions as expected + +## Structure + +- **HelloCitiesOrchestration.cs** - Simple orchestration that calls multiple activities +- **Program.cs** - Azure Functions host entry point +- **host.json** - Azure Functions host configuration +- **local.settings.json** - Local development settings +- **Dockerfile** - Docker image configuration for the Functions app +- **run-smoketests.ps1** - PowerShell script to run smoke tests locally or in CI + +## Running Smoke Tests Locally + +### Prerequisites + +- Docker installed and running +- PowerShell Core (pwsh) installed +- .NET 8.0 SDK or later + +### Run the Tests + +From the `test/AzureFunctionsSmokeTests` directory: + +```bash +pwsh -File run-smoketests.ps1 +``` + +The script will: +1. Build and publish the Azure Functions project +2. Create a Docker image +3. Start Azurite (Azure Storage emulator) in a Docker container +4. Start the Azure Functions app in a Docker container +5. Trigger the HelloCities orchestration via HTTP +6. Poll for orchestration completion +7. Validate the result +8. Clean up all containers + +### Parameters + +The script accepts the following optional parameters: + +```powershell +pwsh -File run-smoketests.ps1 ` + -ImageName "custom-image-name" ` + -ContainerName "custom-container-name" ` + -Port 8080 ` + -Timeout 120 +``` + +## CI Integration + +The smoke tests are automatically run in GitHub Actions via the `.github/workflows/azure-functions-smoke-tests.yml` workflow on: +- Push to `main` or `feature/**` branches +- Pull requests targeting `main` or `feature/**` branches +- Manual workflow dispatch + +## Troubleshooting + +If the smoke tests fail: + +1. **Check container logs**: The script will display logs automatically on failure +2. **Verify Azurite is running**: Ensure port 10000-10002 are available +3. **Check Functions app port**: Ensure the configured port (default 8080) is available +4. **Build errors**: Ensure all dependencies are restored with `dotnet restore` + +## Adding New Smoke Tests + +To add new orchestration scenarios: + +1. Create new function classes following the pattern in `HelloCitiesOrchestration.cs` +2. Ensure proper XML documentation comments +3. Add test logic to validate the new scenario +4. Update this README with the new test case diff --git a/test/AzureFunctionsSmokeTests/host.json b/test/AzureFunctionsSmokeTests/host.json new file mode 100644 index 000000000..305e9bf49 --- /dev/null +++ b/test/AzureFunctionsSmokeTests/host.json @@ -0,0 +1,21 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Default": "Information", + "DurableTask.AzureStorage": "Warning", + "DurableTask.Core": "Warning" + }, + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensions": { + "durableTask": { + "hubName": "DotNetIsolatedSmokeTests" + } + } +} diff --git a/test/AzureFunctionsSmokeTests/local.settings.json b/test/AzureFunctionsSmokeTests/local.settings.json new file mode 100644 index 000000000..8eea88f48 --- /dev/null +++ b/test/AzureFunctionsSmokeTests/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated" + } +} diff --git a/test/AzureFunctionsSmokeTests/run-smoketests.ps1 b/test/AzureFunctionsSmokeTests/run-smoketests.ps1 new file mode 100644 index 000000000..185b9e3df --- /dev/null +++ b/test/AzureFunctionsSmokeTests/run-smoketests.ps1 @@ -0,0 +1,271 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS + Runs smoke tests for the Azure Functions app using Docker containers. +.DESCRIPTION + This script builds and publishes the Azure Functions smoke test app, + starts required containers (Azurite for storage emulation and the Functions app), + triggers the orchestration, and validates successful completion. +.PARAMETER ImageName + Docker image name for the Functions app (default: "azurefunctions-smoketests") +.PARAMETER ContainerName + Docker container name for the Functions app (default: "azurefunctions-smoketests-container") +.PARAMETER Port + Port to expose the Functions app on (default: 8080) +.PARAMETER Timeout + Timeout in seconds to wait for orchestration completion (default: 120) +#> + +param( + [string]$ImageName = "azurefunctions-smoketests", + [string]$ContainerName = "azurefunctions-smoketests-container", + [int]$Port = 8080, + [int]$Timeout = 120 +) + +$ErrorActionPreference = "Stop" + +# Get the directory where the script is located +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$projectDir = $scriptDir +$publishDir = Join-Path $projectDir "publish" + +Write-Host "=== Azure Functions Smoke Test Runner ===" -ForegroundColor Cyan +Write-Host "" + +# Function to clean up containers +function Cleanup { + Write-Host "Cleaning up containers..." -ForegroundColor Yellow + + # Stop and remove the Functions app container + docker stop $ContainerName 2>$null | Out-Null + docker rm $ContainerName 2>$null | Out-Null + + # Stop and remove Azurite container + docker stop azurite-smoketest 2>$null | Out-Null + docker rm azurite-smoketest 2>$null | Out-Null + + # Remove the Docker network + docker network rm smoketest-network 2>$null | Out-Null +} + +# Cleanup on script exit +trap { + Write-Host "Error occurred. Cleaning up..." -ForegroundColor Red + Cleanup + exit 1 +} + +try { + # Cleanup any existing containers first + Write-Host "Cleaning up any existing containers..." -ForegroundColor Yellow + Cleanup + Write-Host "" + + # Step 1: Build the project + Write-Host "Step 1: Building the Azure Functions project..." -ForegroundColor Green + dotnet build $projectDir -c Release + if ($LASTEXITCODE -ne 0) { + throw "Build failed with exit code $LASTEXITCODE" + } + Write-Host "Build completed successfully." -ForegroundColor Green + Write-Host "" + + # Step 2: Publish the project + Write-Host "Step 2: Publishing the Azure Functions project..." -ForegroundColor Green + if (Test-Path $publishDir) { + Remove-Item $publishDir -Recurse -Force + } + dotnet publish $projectDir -c Release -o $publishDir + if ($LASTEXITCODE -ne 0) { + throw "Publish failed with exit code $LASTEXITCODE" + } + Write-Host "Publish completed successfully." -ForegroundColor Green + Write-Host "" + + # Step 3: Build Docker image + Write-Host "Step 3: Building Docker image '$ImageName'..." -ForegroundColor Green + docker build -t $ImageName $projectDir + if ($LASTEXITCODE -ne 0) { + throw "Docker build failed with exit code $LASTEXITCODE" + } + Write-Host "Docker image built successfully." -ForegroundColor Green + Write-Host "" + + # Step 4: Create Docker network + Write-Host "Step 4: Creating Docker network..." -ForegroundColor Green + docker network create smoketest-network 2>$null + Write-Host "Docker network created or already exists." -ForegroundColor Green + Write-Host "" + + # Step 5: Start Azurite container + Write-Host "Step 5: Starting Azurite storage emulator..." -ForegroundColor Green + docker run -d ` + --name azurite-smoketest ` + --network smoketest-network ` + -p 10000:10000 ` + -p 10001:10001 ` + -p 10002:10002 ` + mcr.microsoft.com/azure-storage/azurite:latest + + if ($LASTEXITCODE -ne 0) { + throw "Failed to start Azurite container" + } + + # Wait for Azurite to be ready + Write-Host "Waiting for Azurite to be ready..." -ForegroundColor Yellow + Start-Sleep -Seconds 5 + Write-Host "Azurite is ready." -ForegroundColor Green + Write-Host "" + + # Step 6: Start Azure Functions container + Write-Host "Step 6: Starting Azure Functions container..." -ForegroundColor Green + + # Azurite connection string for Docker network + # Using the default Azurite development account credentials + $accountName = "devstoreaccount1" + $accountKey = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==" + $blobEndpoint = "http://azurite-smoketest:10000/$accountName" + $queueEndpoint = "http://azurite-smoketest:10001/$accountName" + $tableEndpoint = "http://azurite-smoketest:10002/$accountName" + + $storageConnectionString = @( + "DefaultEndpointsProtocol=http" + "AccountName=$accountName" + "AccountKey=$accountKey" + "BlobEndpoint=$blobEndpoint" + "QueueEndpoint=$queueEndpoint" + "TableEndpoint=$tableEndpoint" + ) -join ";" + + docker run -d ` + --name $ContainerName ` + --network smoketest-network ` + -p "${Port}:80" ` + -e AzureWebJobsStorage="$storageConnectionString" ` + -e FUNCTIONS_WORKER_RUNTIME=dotnet-isolated ` + -e WEBSITE_HOSTNAME="localhost:$Port" ` + $ImageName + + if ($LASTEXITCODE -ne 0) { + throw "Failed to start Functions container" + } + + # Wait for Functions host to start + Write-Host "Waiting for Azure Functions host to start..." -ForegroundColor Yellow + + # Give the host time to fully initialize + # The admin/host/status endpoint is not available in all configurations, + # so we'll wait a reasonable amount of time and check logs + Start-Sleep -Seconds 15 + + # Check if the container is still running + $containerStatus = docker inspect --format='{{.State.Status}}' $ContainerName + if ($containerStatus -ne "running") { + Write-Host "Functions container is not running. Checking logs..." -ForegroundColor Red + docker logs $ContainerName + throw "Functions container failed to start" + } + + # Check logs for successful startup + $logs = docker logs $ContainerName 2>&1 | Out-String + if ($logs -match "Job host started" -or $logs -match "Host started") { + Write-Host "Azure Functions host is ready." -ForegroundColor Green + } + else { + Write-Host "Warning: Could not confirm host startup from logs." -ForegroundColor Yellow + Write-Host "Attempting to continue with orchestration trigger..." -ForegroundColor Yellow + } + Write-Host "" + + # Step 7: Trigger orchestration + Write-Host "Step 7: Triggering orchestration..." -ForegroundColor Green + $startUrl = "http://localhost:$Port/api/HelloCitiesOrchestration_HttpStart" + + try { + $startResponse = Invoke-WebRequest -Uri $startUrl -Method Post -UseBasicParsing + if ($startResponse.StatusCode -ne 202) { + throw "Unexpected status code: $($startResponse.StatusCode)" + } + } + catch { + Write-Host "Failed to trigger orchestration. Error: $_" -ForegroundColor Red + Write-Host "Container logs:" -ForegroundColor Yellow + docker logs $ContainerName + throw + } + + $responseContent = $startResponse.Content | ConvertFrom-Json + $statusQueryGetUri = $responseContent.statusQueryGetUri + $instanceId = $responseContent.id + + Write-Host "Orchestration started with instance ID: $instanceId" -ForegroundColor Green + Write-Host "Status query URI: $statusQueryGetUri" -ForegroundColor Cyan + Write-Host "" + + # Step 8: Poll for completion + Write-Host "Step 8: Polling for orchestration completion..." -ForegroundColor Green + $startTime = Get-Date + $completed = $false + $consecutiveErrors = 0 + $maxConsecutiveErrors = 3 + + while (((Get-Date) - $startTime).TotalSeconds -lt $Timeout) { + Start-Sleep -Seconds 2 + + try { + $statusResponse = Invoke-WebRequest -Uri $statusQueryGetUri -UseBasicParsing + $status = $statusResponse.Content | ConvertFrom-Json + + # Reset error counter on successful poll + $consecutiveErrors = 0 + + Write-Host "Current status: $($status.runtimeStatus)" -ForegroundColor Yellow + + if ($status.runtimeStatus -eq "Completed") { + $completed = $true + Write-Host "" + Write-Host "Orchestration completed successfully!" -ForegroundColor Green + Write-Host "Output: $($status.output)" -ForegroundColor Cyan + break + } + elseif ($status.runtimeStatus -eq "Failed" -or $status.runtimeStatus -eq "Terminated") { + throw "Orchestration ended with status: $($status.runtimeStatus)" + } + } + catch { + $consecutiveErrors++ + Write-Host "Error polling status (attempt $consecutiveErrors/$maxConsecutiveErrors): $_" -ForegroundColor Red + + if ($consecutiveErrors -ge $maxConsecutiveErrors) { + Write-Host "Container logs:" -ForegroundColor Yellow + docker logs $ContainerName + throw "Too many consecutive errors polling orchestration status" + } + } + } + + if (-not $completed) { + Write-Host "Container logs:" -ForegroundColor Yellow + docker logs $ContainerName + throw "Orchestration did not complete within timeout period" + } + + Write-Host "" + Write-Host "=== Smoke test completed successfully! ===" -ForegroundColor Green +} +finally { + # Cleanup + Cleanup + + # Cleanup publish directory + if (Test-Path $publishDir) { + Write-Host "Cleaning up publish directory..." -ForegroundColor Yellow + Remove-Item $publishDir -Recurse -Force + } +} + +Write-Host "" +Write-Host "All smoke tests passed!" -ForegroundColor Green +exit 0 diff --git a/test/Benchmarks/Benchmarks.csproj b/test/Benchmarks/Benchmarks.csproj index 3101371c1..b71a25c5d 100644 --- a/test/Benchmarks/Benchmarks.csproj +++ b/test/Benchmarks/Benchmarks.csproj @@ -14,6 +14,7 @@ + diff --git a/test/Client/Grpc.Tests/GrpcDurableTaskClientTests.cs b/test/Client/Grpc.Tests/GrpcDurableTaskClientTests.cs new file mode 100644 index 000000000..8d098106c --- /dev/null +++ b/test/Client/Grpc.Tests/GrpcDurableTaskClientTests.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Grpc.Core; +using Microsoft.DurableTask.Client; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DurableTask.Client.Grpc.Tests; + +public class GrpcDurableTaskClientTests +{ + readonly Mock loggerMock = new(); + + GrpcDurableTaskClient CreateClient() + { + var callInvoker = Mock.Of(); + var options = new GrpcDurableTaskClientOptions + { + CallInvoker = callInvoker, + }; + + return new GrpcDurableTaskClient("test", options, this.loggerMock.Object); + } + + [Fact] + public async Task ScheduleNewOrchestrationInstanceAsync_InvalidDedupeStatus_ThrowsArgumentException() + { + // Arrange + var client = this.CreateClient(); + var startOptions = new StartOrchestrationOptions + { + DedupeStatuses = new[] { "InvalidStatus", "AnotherInvalidStatus" }, + }; + + // Act & Assert + Func act = async () => await client.ScheduleNewOrchestrationInstanceAsync( + new TaskName("TestOrchestration"), + input: null, + startOptions); + + var exception = await act.Should().ThrowAsync(); + exception.Which.Message.Should().Contain("Invalid orchestration runtime status: 'InvalidStatus' for deduplication."); + } + + [Fact] + public async Task ScheduleNewOrchestrationInstanceAsync_InvalidDedupeStatus_ContainsInvalidStatusInMessage() + { + // Arrange + var client = this.CreateClient(); + var startOptions = new StartOrchestrationOptions + { + DedupeStatuses = new[] { "NonExistentStatus" }, + }; + + // Act & Assert + Func act = async () => await client.ScheduleNewOrchestrationInstanceAsync( + new TaskName("TestOrchestration"), + input: null, + startOptions); + + var exception = await act.Should().ThrowAsync(); + exception.Which.Message.Should().Contain("'NonExistentStatus'"); + exception.Which.Message.Should().Contain("for deduplication"); + } + + [Fact] + public async Task ScheduleNewOrchestrationInstanceAsync_MixedValidAndInvalidStatus_ThrowsArgumentException() + { + // Arrange + var client = this.CreateClient(); + var startOptions = new StartOrchestrationOptions + { + DedupeStatuses = new[] { "Completed", "InvalidStatus", "Failed" }, + }; + + // Act & Assert + Func act = async () => await client.ScheduleNewOrchestrationInstanceAsync( + new TaskName("TestOrchestration"), + input: null, + startOptions); + + var exception = await act.Should().ThrowAsync(); + exception.Which.Message.Should().Contain("Invalid orchestration runtime status: 'InvalidStatus' for deduplication."); + } + + [Fact] + public async Task ScheduleNewOrchestrationInstanceAsync_CaseInsensitiveValidStatus_DoesNotThrowArgumentException() + { + // Arrange + var client = this.CreateClient(); + var startOptions = new StartOrchestrationOptions + { + DedupeStatuses = new[] { "completed", "FAILED", "Terminated" }, + }; + + // Act & Assert - Case-insensitive parsing should work, so no ArgumentException should be thrown + // The call will fail at the gRPC level, but validation should pass + Func act = async () => await client.ScheduleNewOrchestrationInstanceAsync( + new TaskName("TestOrchestration"), + input: null, + startOptions); + + // Should not throw ArgumentException for invalid status (case-insensitive parsing works) + // It may throw other exceptions due to gRPC call failure, but not ArgumentException + var exception = await act.Should().ThrowAsync(); + exception.Which.Should().NotBeOfType(); + } + + [Fact] + public async Task ScheduleNewOrchestrationInstanceAsync_ValidDedupeStatus_DoesNotThrowArgumentException() + { + // Arrange + var client = this.CreateClient(); + var startOptions = new StartOrchestrationOptions + { + DedupeStatuses = new[] { "Completed", "Failed" }, + }; + + // Act & Assert - Valid statuses should pass validation + // The call will fail at the gRPC level, but validation should pass + Func act = async () => await client.ScheduleNewOrchestrationInstanceAsync( + new TaskName("TestOrchestration"), + input: null, + startOptions); + + // Should not throw ArgumentException for invalid status since "Completed" and "Failed" are valid + var exception = await act.Should().ThrowAsync(); + exception.Which.Should().NotBeOfType(); + } +} + diff --git a/test/Client/Grpc.Tests/ProtoUtilsTests.cs b/test/Client/Grpc.Tests/ProtoUtilsTests.cs new file mode 100644 index 000000000..4db7a8845 --- /dev/null +++ b/test/Client/Grpc.Tests/ProtoUtilsTests.cs @@ -0,0 +1,463 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using P = Microsoft.DurableTask.Protobuf; + +namespace Microsoft.DurableTask.Client.Grpc.Tests; + +public class ProtoUtilsTests +{ + [Fact] + public void GetTerminalStatuses_ReturnsExpectedStatuses() + { + // Act + ImmutableArray terminalStatuses = ProtoUtils.GetTerminalStatuses(); + + // Assert + terminalStatuses.Should().HaveCount(4); + terminalStatuses.Should().Contain(P.OrchestrationStatus.Completed); + terminalStatuses.Should().Contain(P.OrchestrationStatus.Failed); + terminalStatuses.Should().Contain(P.OrchestrationStatus.Terminated); +#pragma warning disable CS0618 // Type or member is obsolete + terminalStatuses.Should().Contain(P.OrchestrationStatus.Canceled); +#pragma warning restore CS0618 + } + + [Fact] + public void GetTerminalStatuses_ReturnsImmutableArray() + { + // Act + ImmutableArray terminalStatuses = ProtoUtils.GetTerminalStatuses(); + + // Assert + terminalStatuses.IsDefault.Should().BeFalse(); + terminalStatuses.IsEmpty.Should().BeFalse(); + } + + [Fact] + public void ConvertDedupeStatusesToReusePolicy_EmptyArray_ReturnsPolicyWithAllTerminalStatuses() + { + // Arrange + var dedupeStatuses = Array.Empty(); + + // Act + P.OrchestrationIdReusePolicy? result = ProtoUtils.ConvertDedupeStatusesToReusePolicy(dedupeStatuses); + + // Assert + // Empty array means no dedupe statuses, so all terminal statuses are replaceable + result.Should().NotBeNull(); + result!.ReplaceableStatus.Should().HaveCount(4); + } + + [Fact] + public void ConvertDedupeStatusesToReusePolicy_AllTerminalStatuses_ReturnsNull() + { + // Arrange + ImmutableArray allTerminalStatuses = ProtoUtils.GetTerminalStatuses(); + var dedupeStatuses = allTerminalStatuses.ToArray(); + + // Act + P.OrchestrationIdReusePolicy? result = ProtoUtils.ConvertDedupeStatusesToReusePolicy(dedupeStatuses); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void ConvertDedupeStatusesToReusePolicy_NoDedupeStatuses_ReturnsPolicyWithAllTerminalStatuses() + { + // Arrange + var dedupeStatuses = Array.Empty(); + + // Act + P.OrchestrationIdReusePolicy? result = ProtoUtils.ConvertDedupeStatusesToReusePolicy(dedupeStatuses); + + // Assert + // When no dedupe statuses, all terminal statuses should be replaceable + result.Should().NotBeNull(); + result!.ReplaceableStatus.Should().HaveCount(4); + result.ReplaceableStatus.Should().Contain(P.OrchestrationStatus.Completed); + result.ReplaceableStatus.Should().Contain(P.OrchestrationStatus.Failed); + result.ReplaceableStatus.Should().Contain(P.OrchestrationStatus.Terminated); +#pragma warning disable CS0618 // Type or member is obsolete + result.ReplaceableStatus.Should().Contain(P.OrchestrationStatus.Canceled); +#pragma warning restore CS0618 + } + + [Fact] + public void ConvertDedupeStatusesToReusePolicy_SingleDedupeStatus_ReturnsPolicyWithRemainingStatuses() + { + // Arrange + var dedupeStatuses = new[] { P.OrchestrationStatus.Completed }; + + // Act + P.OrchestrationIdReusePolicy? result = ProtoUtils.ConvertDedupeStatusesToReusePolicy(dedupeStatuses); + + // Assert + result.Should().NotBeNull(); + result!.ReplaceableStatus.Should().HaveCount(3); + result.ReplaceableStatus.Should().Contain(P.OrchestrationStatus.Failed); + result.ReplaceableStatus.Should().Contain(P.OrchestrationStatus.Terminated); +#pragma warning disable CS0618 // Type or member is obsolete + result.ReplaceableStatus.Should().Contain(P.OrchestrationStatus.Canceled); +#pragma warning restore CS0618 + result.ReplaceableStatus.Should().NotContain(P.OrchestrationStatus.Completed); + } + + [Fact] + public void ConvertDedupeStatusesToReusePolicy_MultipleDedupeStatuses_ReturnsPolicyWithRemainingStatuses() + { + // Arrange + var dedupeStatuses = new[] + { + P.OrchestrationStatus.Completed, + P.OrchestrationStatus.Failed + }; + + // Act + P.OrchestrationIdReusePolicy? result = ProtoUtils.ConvertDedupeStatusesToReusePolicy(dedupeStatuses); + + // Assert + result.Should().NotBeNull(); + result!.ReplaceableStatus.Should().HaveCount(2); + result.ReplaceableStatus.Should().Contain(P.OrchestrationStatus.Terminated); +#pragma warning disable CS0618 // Type or member is obsolete + result.ReplaceableStatus.Should().Contain(P.OrchestrationStatus.Canceled); +#pragma warning restore CS0618 + result.ReplaceableStatus.Should().NotContain(P.OrchestrationStatus.Completed); + result.ReplaceableStatus.Should().NotContain(P.OrchestrationStatus.Failed); + } + + [Fact] + public void ConvertDedupeStatusesToReusePolicy_DuplicateDedupeStatuses_HandlesDuplicates() + { + // Arrange + var dedupeStatuses = new[] + { + P.OrchestrationStatus.Completed, + P.OrchestrationStatus.Completed, + P.OrchestrationStatus.Failed + }; + + // Act + P.OrchestrationIdReusePolicy? result = ProtoUtils.ConvertDedupeStatusesToReusePolicy(dedupeStatuses); + + // Assert + result.Should().NotBeNull(); + result!.ReplaceableStatus.Should().HaveCount(2); + result.ReplaceableStatus.Should().Contain(P.OrchestrationStatus.Terminated); +#pragma warning disable CS0618 // Type or member is obsolete + result.ReplaceableStatus.Should().Contain(P.OrchestrationStatus.Canceled); +#pragma warning restore CS0618 + } + + [Fact] + public void ConvertDedupeStatusesToReusePolicy_NonTerminalStatus_IgnoresNonTerminalStatus() + { + // Arrange + var dedupeStatuses = new[] + { + P.OrchestrationStatus.Completed, + P.OrchestrationStatus.Running, // Non-terminal status + P.OrchestrationStatus.Pending // Non-terminal status + }; + + // Act + P.OrchestrationIdReusePolicy? result = ProtoUtils.ConvertDedupeStatusesToReusePolicy(dedupeStatuses); + + // Assert + result.Should().NotBeNull(); + result!.ReplaceableStatus.Should().HaveCount(3); + result.ReplaceableStatus.Should().Contain(P.OrchestrationStatus.Failed); + result.ReplaceableStatus.Should().Contain(P.OrchestrationStatus.Terminated); +#pragma warning disable CS0618 // Type or member is obsolete + result.ReplaceableStatus.Should().Contain(P.OrchestrationStatus.Canceled); +#pragma warning restore CS0618 + result.ReplaceableStatus.Should().NotContain(P.OrchestrationStatus.Completed); + } + + [Fact] + public void ConvertReusePolicyToDedupeStatuses_NullPolicy_ReturnsNull() + { + // Arrange + P.OrchestrationIdReusePolicy? policy = null; + + // Act + P.OrchestrationStatus[]? result = ProtoUtils.ConvertReusePolicyToDedupeStatuses(policy); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void ConvertReusePolicyToDedupeStatuses_EmptyPolicy_ReturnsNull() + { + // Arrange + var policy = new P.OrchestrationIdReusePolicy(); + + // Act + P.OrchestrationStatus[]? result = ProtoUtils.ConvertReusePolicyToDedupeStatuses(policy); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void ConvertReusePolicyToDedupeStatuses_AllTerminalStatusesReplaceable_ReturnsNull() + { + // Arrange + var policy = new P.OrchestrationIdReusePolicy(); + ImmutableArray terminalStatuses = ProtoUtils.GetTerminalStatuses(); + foreach (var status in terminalStatuses) + { + policy.ReplaceableStatus.Add(status); + } + + // Act + P.OrchestrationStatus[]? result = ProtoUtils.ConvertReusePolicyToDedupeStatuses(policy); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void ConvertReusePolicyToDedupeStatuses_SingleReplaceableStatus_ReturnsRemainingStatuses() + { + // Arrange + var policy = new P.OrchestrationIdReusePolicy(); + policy.ReplaceableStatus.Add(P.OrchestrationStatus.Completed); + + // Act + P.OrchestrationStatus[]? result = ProtoUtils.ConvertReusePolicyToDedupeStatuses(policy); + + // Assert + result.Should().NotBeNull(); + result!.Should().HaveCount(3); + result.Should().Contain(P.OrchestrationStatus.Failed); + result.Should().Contain(P.OrchestrationStatus.Terminated); +#pragma warning disable CS0618 // Type or member is obsolete + result.Should().Contain(P.OrchestrationStatus.Canceled); +#pragma warning restore CS0618 + result.Should().NotContain(P.OrchestrationStatus.Completed); + } + + [Fact] + public void ConvertReusePolicyToDedupeStatuses_MultipleReplaceableStatuses_ReturnsRemainingStatuses() + { + // Arrange + var policy = new P.OrchestrationIdReusePolicy(); + policy.ReplaceableStatus.Add(P.OrchestrationStatus.Completed); + policy.ReplaceableStatus.Add(P.OrchestrationStatus.Failed); + + // Act + P.OrchestrationStatus[]? result = ProtoUtils.ConvertReusePolicyToDedupeStatuses(policy); + + // Assert + result.Should().NotBeNull(); + result!.Should().HaveCount(2); + result.Should().Contain(P.OrchestrationStatus.Terminated); +#pragma warning disable CS0618 // Type or member is obsolete + result.Should().Contain(P.OrchestrationStatus.Canceled); +#pragma warning restore CS0618 + result.Should().NotContain(P.OrchestrationStatus.Completed); + result.Should().NotContain(P.OrchestrationStatus.Failed); + } + + [Fact] + public void ConvertReusePolicyToDedupeStatuses_NonTerminalStatusInPolicy_IgnoresNonTerminalStatus() + { + // Arrange + var policy = new P.OrchestrationIdReusePolicy(); + policy.ReplaceableStatus.Add(P.OrchestrationStatus.Completed); + policy.ReplaceableStatus.Add(P.OrchestrationStatus.Running); // Non-terminal status + policy.ReplaceableStatus.Add(P.OrchestrationStatus.Pending); // Non-terminal status + + // Act + P.OrchestrationStatus[]? result = ProtoUtils.ConvertReusePolicyToDedupeStatuses(policy); + + // Assert + result.Should().NotBeNull(); + result!.Should().HaveCount(3); + result.Should().Contain(P.OrchestrationStatus.Failed); + result.Should().Contain(P.OrchestrationStatus.Terminated); +#pragma warning disable CS0618 // Type or member is obsolete + result.Should().Contain(P.OrchestrationStatus.Canceled); +#pragma warning restore CS0618 + result.Should().NotContain(P.OrchestrationStatus.Completed); + } + + [Fact] + public void ConvertReusePolicyToDedupeStatuses_DuplicateReplaceableStatuses_HandlesDuplicates() + { + // Arrange + var policy = new P.OrchestrationIdReusePolicy(); + policy.ReplaceableStatus.Add(P.OrchestrationStatus.Completed); + policy.ReplaceableStatus.Add(P.OrchestrationStatus.Completed); // Duplicate + policy.ReplaceableStatus.Add(P.OrchestrationStatus.Failed); + + // Act + P.OrchestrationStatus[]? result = ProtoUtils.ConvertReusePolicyToDedupeStatuses(policy); + + // Assert + result.Should().NotBeNull(); + result!.Should().HaveCount(2); + result.Should().Contain(P.OrchestrationStatus.Terminated); +#pragma warning disable CS0618 // Type or member is obsolete + result.Should().Contain(P.OrchestrationStatus.Canceled); +#pragma warning restore CS0618 + } + + [Fact] + public void ConvertDedupeStatusesToReusePolicy_ThenConvertBack_ReturnsOriginalDedupeStatuses() + { + // Arrange + var originalDedupeStatuses = new[] + { + P.OrchestrationStatus.Completed, + P.OrchestrationStatus.Failed + }; + + // Act + P.OrchestrationIdReusePolicy? policy = ProtoUtils.ConvertDedupeStatusesToReusePolicy(originalDedupeStatuses); + P.OrchestrationStatus[]? convertedBack = ProtoUtils.ConvertReusePolicyToDedupeStatuses(policy); + + // Assert + convertedBack.Should().NotBeNull(); + convertedBack!.Should().BeEquivalentTo(originalDedupeStatuses); + } + + [Fact] + public void ConvertReusePolicyToDedupeStatuses_ThenConvertBack_ReturnsOriginalPolicy() + { + // Arrange + var policy = new P.OrchestrationIdReusePolicy(); + policy.ReplaceableStatus.Add(P.OrchestrationStatus.Completed); + policy.ReplaceableStatus.Add(P.OrchestrationStatus.Failed); + + // Act + P.OrchestrationStatus[]? dedupeStatuses = ProtoUtils.ConvertReusePolicyToDedupeStatuses(policy); + P.OrchestrationIdReusePolicy? convertedBack = ProtoUtils.ConvertDedupeStatusesToReusePolicy(dedupeStatuses); + + // Assert + convertedBack.Should().NotBeNull(); + convertedBack!.ReplaceableStatus.Should().BeEquivalentTo(policy.ReplaceableStatus); + } + + [Fact] + public void ConvertDedupeStatusesToReusePolicy_AllStatuses_ThenConvertBack_ReturnsNull() + { + // Arrange + ImmutableArray allTerminalStatuses = ProtoUtils.GetTerminalStatuses(); + var dedupeStatuses = allTerminalStatuses.ToArray(); + + // Act + P.OrchestrationIdReusePolicy? policy = ProtoUtils.ConvertDedupeStatusesToReusePolicy(dedupeStatuses); + P.OrchestrationStatus[]? convertedBack = ProtoUtils.ConvertReusePolicyToDedupeStatuses(policy); + + // Assert + policy.Should().BeNull(); + convertedBack.Should().BeNull(); + } + + [Fact] + public void ConvertReusePolicyToDedupeStatuses_AllStatuses_ThenConvertBack_ReturnsPolicyWithAllStatuses() + { + // Arrange + var policy = new P.OrchestrationIdReusePolicy(); + ImmutableArray terminalStatuses = ProtoUtils.GetTerminalStatuses(); + foreach (var status in terminalStatuses) + { + policy.ReplaceableStatus.Add(status); + } + + // Act + P.OrchestrationStatus[]? dedupeStatuses = ProtoUtils.ConvertReusePolicyToDedupeStatuses(policy); + P.OrchestrationIdReusePolicy? convertedBack = ProtoUtils.ConvertDedupeStatusesToReusePolicy(dedupeStatuses); + + // Assert + // Policy with all statuses -> no dedupe statuses -> null + // null dedupe statuses -> all are replaceable -> policy with all statuses + dedupeStatuses.Should().BeNull(); + convertedBack.Should().NotBeNull(); + convertedBack!.ReplaceableStatus.Should().HaveCount(4); + convertedBack.ReplaceableStatus.Should().BeEquivalentTo(policy.ReplaceableStatus); + } + + [Fact] + public void ConvertDedupeStatusesToReusePolicy_EmptyArray_ThenConvertBack_ReturnsNull() + { + // Arrange + var dedupeStatuses = Array.Empty(); + + // Act + P.OrchestrationIdReusePolicy? policy = ProtoUtils.ConvertDedupeStatusesToReusePolicy(dedupeStatuses); + P.OrchestrationStatus[]? convertedBack = ProtoUtils.ConvertReusePolicyToDedupeStatuses(policy); + + // Assert + // Empty dedupe statuses -> all terminal statuses are replaceable -> policy with all statuses + // Policy with all statuses -> no dedupe statuses -> null + policy.Should().NotBeNull(); + convertedBack.Should().BeNull(); + } + + [Fact] + public void ConvertReusePolicyToDedupeStatuses_EmptyPolicy_ThenConvertBack_ReturnsPolicyWithAllStatuses() + { + // Arrange + var policy = new P.OrchestrationIdReusePolicy(); + + // Act + P.OrchestrationStatus[]? dedupeStatuses = ProtoUtils.ConvertReusePolicyToDedupeStatuses(policy); + P.OrchestrationIdReusePolicy? convertedBack = ProtoUtils.ConvertDedupeStatusesToReusePolicy(dedupeStatuses); + + // Assert + // Empty policy (no replaceable statuses) -> ConvertReusePolicyToDedupeStatuses returns null + // null dedupe statuses -> all terminal statuses are replaceable -> policy with all statuses + dedupeStatuses.Should().BeNull(); + convertedBack.Should().NotBeNull(); + convertedBack!.ReplaceableStatus.Should().HaveCount(4); + } + + [Theory] + [InlineData(P.OrchestrationStatus.Completed)] + [InlineData(P.OrchestrationStatus.Failed)] + [InlineData(P.OrchestrationStatus.Terminated)] + public void ConvertDedupeStatusesToReusePolicy_SingleStatus_ThenConvertBack_ReturnsOriginal( + P.OrchestrationStatus dedupeStatus) + { + // Arrange + var dedupeStatuses = new[] { dedupeStatus }; + + // Act + P.OrchestrationIdReusePolicy? policy = ProtoUtils.ConvertDedupeStatusesToReusePolicy(dedupeStatuses); + P.OrchestrationStatus[]? convertedBack = ProtoUtils.ConvertReusePolicyToDedupeStatuses(policy); + + // Assert + convertedBack.Should().NotBeNull(); + convertedBack!.Should().ContainSingle(); + convertedBack.Should().Contain(dedupeStatus); + } + + [Fact] + public void ConvertDedupeStatusesToReusePolicy_ThreeOutOfFourStatuses_ThenConvertBack_ReturnsOriginal() + { + // Arrange + var dedupeStatuses = new[] + { + P.OrchestrationStatus.Completed, + P.OrchestrationStatus.Failed, + P.OrchestrationStatus.Terminated + }; + + // Act + P.OrchestrationIdReusePolicy? policy = ProtoUtils.ConvertDedupeStatusesToReusePolicy(dedupeStatuses); + P.OrchestrationStatus[]? convertedBack = ProtoUtils.ConvertReusePolicyToDedupeStatuses(policy); + + // Assert + convertedBack.Should().NotBeNull(); + convertedBack!.Should().HaveCount(3); + convertedBack.Should().BeEquivalentTo(dedupeStatuses); + } +} + diff --git a/test/Client/OrchestrationServiceClientShim.Tests/ShimDurableTaskClientTests.cs b/test/Client/OrchestrationServiceClientShim.Tests/ShimDurableTaskClientTests.cs index fa65cc42a..9509d1e0b 100644 --- a/test/Client/OrchestrationServiceClientShim.Tests/ShimDurableTaskClientTests.cs +++ b/test/Client/OrchestrationServiceClientShim.Tests/ShimDurableTaskClientTests.cs @@ -326,6 +326,195 @@ public async Task ScheduleNewOrchestrationInstance_IdProvided_TagsProvided() await this.RunScheduleNewOrchestrationInstanceAsync("test", "input", options); } + [Fact] + public async Task ScheduleNewOrchestrationInstance_InvalidDedupeStatus_ThrowsArgumentException() + { + // Arrange + StartOrchestrationOptions options = new() + { + DedupeStatuses = new[] { "InvalidStatus" }, + }; + + // Act & Assert + Func act = async () => await this.client.ScheduleNewOrchestrationInstanceAsync( + new TaskName("TestOrchestration"), + input: null, + options, + default); + + var exception = await act.Should().ThrowAsync(); + exception.Which.Message.Should().Contain("Invalid orchestration runtime status: 'InvalidStatus' for deduplication."); + } + + [Fact] + public async Task ScheduleNewOrchestrationInstance_InvalidDedupeStatus_ContainsInvalidStatusInMessage() + { + // Arrange + StartOrchestrationOptions options = new() + { + DedupeStatuses = new[] { "NonExistentStatus" }, + }; + + // Act & Assert + Func act = async () => await this.client.ScheduleNewOrchestrationInstanceAsync( + new TaskName("TestOrchestration"), + input: null, + options, + default); + + var exception = await act.Should().ThrowAsync(); + exception.Which.Message.Should().Contain("'NonExistentStatus'"); + exception.Which.Message.Should().Contain("for deduplication"); + } + + [Fact] + public async Task ScheduleNewOrchestrationInstance_MultipleInvalidDedupeStatuses_ThrowsOnFirstInvalid() + { + // Arrange + StartOrchestrationOptions options = new() + { + DedupeStatuses = new[] { "InvalidStatus1", "InvalidStatus2", "InvalidStatus3" }, + }; + + // Act & Assert + Func act = async () => await this.client.ScheduleNewOrchestrationInstanceAsync( + new TaskName("TestOrchestration"), + input: null, + options, + default); + + var exception = await act.Should().ThrowAsync(); + exception.Which.Message.Should().Contain("'InvalidStatus1'"); + } + + [Fact] + public async Task ScheduleNewOrchestrationInstance_MixedValidAndInvalidStatus_ThrowsArgumentException() + { + // Arrange + StartOrchestrationOptions options = new() + { + DedupeStatuses = new[] { "Completed", "InvalidStatus", "Failed" }, + }; + + // Act & Assert + Func act = async () => await this.client.ScheduleNewOrchestrationInstanceAsync( + new TaskName("TestOrchestration"), + input: null, + options, + default); + + var exception = await act.Should().ThrowAsync(); + exception.Which.Message.Should().Contain("Invalid orchestration runtime status: 'InvalidStatus' for deduplication."); + } + + [Fact] + public async Task ScheduleNewOrchestrationInstance_ValidDedupeStatuses_DoesNotThrow() + { + // Arrange + StartOrchestrationOptions options = new() + { + DedupeStatuses = new[] { "Completed", "Failed" }, + }; + + // Setup the mock to handle the call + this.orchestrationClient.Setup( + m => m.CreateTaskOrchestrationAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + // Act & Assert - Should not throw ArgumentException for invalid status + Func act = async () => await this.client.ScheduleNewOrchestrationInstanceAsync( + new TaskName("TestOrchestration"), + input: null, + options, + default); + + await act.Should().NotThrowAsync(); + this.orchestrationClient.VerifyAll(); + } + + [Fact] + public async Task ScheduleNewOrchestrationInstance_CaseInsensitiveValidStatus_DoesNotThrow() + { + // Arrange + StartOrchestrationOptions options = new() + { + DedupeStatuses = new[] { "completed", "FAILED", "Terminated" }, + }; + + // Setup the mock to handle the call + this.orchestrationClient.Setup( + m => m.CreateTaskOrchestrationAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + // Act & Assert - Case-insensitive parsing should work + Func act = async () => await this.client.ScheduleNewOrchestrationInstanceAsync( + new TaskName("TestOrchestration"), + input: null, + options, + default); + + await act.Should().NotThrowAsync(); + this.orchestrationClient.VerifyAll(); + } + + [Fact] + public async Task ScheduleNewOrchestrationInstance_EmptyDedupeStatuses_DoesNotThrow() + { + // Arrange + StartOrchestrationOptions options = new() + { + DedupeStatuses = new List(), + }; + + // Setup the mock to handle the call + this.orchestrationClient.Setup( + m => m.CreateTaskOrchestrationAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + // Act & Assert + Func act = async () => await this.client.ScheduleNewOrchestrationInstanceAsync( + new TaskName("TestOrchestration"), + input: null, + options, + default); + + await act.Should().NotThrowAsync(); + this.orchestrationClient.VerifyAll(); + } + + [Fact] + public async Task ScheduleNewOrchestrationInstance_NullDedupeStatuses_DoesNotThrow() + { + // Arrange + StartOrchestrationOptions options = new() + { + DedupeStatuses = null, + }; + + // Setup the mock to handle the call + this.orchestrationClient.Setup( + m => m.CreateTaskOrchestrationAsync( + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + // Act & Assert + Func act = async () => await this.client.ScheduleNewOrchestrationInstanceAsync( + new TaskName("TestOrchestration"), + input: null, + options, + default); + + await act.Should().NotThrowAsync(); + this.orchestrationClient.VerifyAll(); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -347,11 +536,11 @@ public async Task RestartAsync_EndToEnd(bool restartWithNewInstanceId) .Setup(x => x.GetOrchestrationStateAsync(originalInstanceId, false)) .ReturnsAsync(new List { originalState }); - // Capture the TaskMessage for verification becasue we will create this message at RestartAsync. + // Capture the TaskMessage for verification because we will create this message at RestartAsync. TaskMessage? capturedMessage = null; this.orchestrationClient - .Setup(x => x.CreateTaskOrchestrationAsync(It.IsAny())) - .Callback(msg => capturedMessage = msg) + .Setup(x => x.CreateTaskOrchestrationAsync(It.IsAny(), It.IsAny())) + .Callback((msg, _) => capturedMessage = msg) .Returns(Task.CompletedTask); string restartedInstanceId = await this.client.RestartAsync(originalInstanceId, restartWithNewInstanceId); @@ -367,7 +556,7 @@ public async Task RestartAsync_EndToEnd(bool restartWithNewInstanceId) // Verify that CreateTaskOrchestrationAsync was called this.orchestrationClient.Verify( - x => x.CreateTaskOrchestrationAsync(It.IsAny()), + x => x.CreateTaskOrchestrationAsync(It.IsAny(), It.IsAny()), Times.Once); // Verify the captured message details @@ -534,7 +723,9 @@ async Task RunScheduleNewOrchestrationInstanceAsync( { // arrange this.orchestrationClient.Setup( - m => m.CreateTaskOrchestrationAsync(MatchStartExecutionMessage(name, input, options))) + m => m.CreateTaskOrchestrationAsync( + MatchStartExecutionMessage(name, input, options), + It.IsAny())) .Returns(Task.CompletedTask); // act @@ -542,7 +733,9 @@ async Task RunScheduleNewOrchestrationInstanceAsync( // assert this.orchestrationClient.Verify( - m => m.CreateTaskOrchestrationAsync(MatchStartExecutionMessage(name, input, options)), + m => m.CreateTaskOrchestrationAsync( + MatchStartExecutionMessage(name, input, options), + It.IsAny()), Times.Once()); if (options?.InstanceId is string str) diff --git a/test/Generators.Tests/Generators.Tests.csproj b/test/Generators.Tests/Generators.Tests.csproj index 409b69c25..4505972e5 100644 --- a/test/Generators.Tests/Generators.Tests.csproj +++ b/test/Generators.Tests/Generators.Tests.csproj @@ -19,6 +19,7 @@ + diff --git a/test/Grpc.IntegrationTests/GrpcDurableTaskClientIntegrationTests.cs b/test/Grpc.IntegrationTests/GrpcDurableTaskClientIntegrationTests.cs index 2d5df3363..18892f1a0 100644 --- a/test/Grpc.IntegrationTests/GrpcDurableTaskClientIntegrationTests.cs +++ b/test/Grpc.IntegrationTests/GrpcDurableTaskClientIntegrationTests.cs @@ -4,15 +4,20 @@ using System.Diagnostics.CodeAnalysis; using FluentAssertions; using FluentAssertions.Execution; +using Grpc.Core; using Microsoft.DurableTask.Client; using Microsoft.DurableTask.Worker; using Xunit.Abstractions; +using RpcException = Grpc.Core.RpcException; +using StatusCode = Grpc.Core.StatusCode; namespace Microsoft.DurableTask.Grpc.Tests; public class DurableTaskGrpcClientIntegrationTests : IntegrationTestBase { const string OrchestrationName = "TestOrchestration"; + const int PollingTimeoutSeconds = 5; + const int PollingIntervalMilliseconds = 100; public DurableTaskGrpcClientIntegrationTests(ITestOutputHelper output, GrpcSidecarFixture sidecarFixture) : base(output, sidecarFixture) @@ -288,6 +293,324 @@ await restartAction.Should().ThrowAsync() .WithMessage("*An orchestration with the instanceId non-existent-instance-id was not found*"); } + [Fact] + public async Task ScheduleNewOrchestrationInstance_WithDedupeStatuses_ThrowsWhenInstanceExists() + { + await using HostTestLifetime server = await this.StartAsync(); + + string instanceId = "dedup-test-instance"; + + // Schedule and complete first orchestration instance + string firstInstanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + OrchestrationName, + input: false, + new StartOrchestrationOptions(instanceId)); + + // Wait for it to complete + await server.Client.WaitForInstanceStartAsync(firstInstanceId, default); + await server.Client.RaiseEventAsync(firstInstanceId, "event", default); + await server.Client.WaitForInstanceCompletionAsync(firstInstanceId, default); + + // Verify it's completed + OrchestrationMetadata? metadata = await server.Client.GetInstanceAsync(instanceId, false); + metadata.Should().NotBeNull(); + metadata!.RuntimeStatus.Should().Be(OrchestrationRuntimeStatus.Completed); + + // Try to create another instance with the same ID and dedupe statuses including Completed + // This should throw an exception + Func createAction = () => server.Client.ScheduleNewOrchestrationInstanceAsync( + OrchestrationName, + input: false, + new StartOrchestrationOptions(instanceId).WithDedupeStatuses(OrchestrationRuntimeStatus.Completed)); + + await createAction.Should().ThrowAsync() + .Where(e => e.StatusCode == StatusCode.AlreadyExists); + } + + [Fact] + public async Task ScheduleNewOrchestrationInstance_WithDedupeStatuses_AllowsReplacementWhenStatusNotInDedupeList() + { + await using HostTestLifetime server = await this.StartAsync(); + + string instanceId = "dedup-test-instance-replace"; + + // Create first orchestration instance + string firstInstanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + OrchestrationName, + input: false, + new StartOrchestrationOptions(instanceId)); + + // Wait for it to complete + await server.Client.WaitForInstanceStartAsync(firstInstanceId, default); + await server.Client.RaiseEventAsync(firstInstanceId, "event", default); + await server.Client.WaitForInstanceCompletionAsync(firstInstanceId, default); + + // Verify it's completed + OrchestrationMetadata? metadata = await server.Client.GetInstanceAsync(instanceId, false); + metadata.Should().NotBeNull(); + metadata!.RuntimeStatus.Should().Be(OrchestrationRuntimeStatus.Completed); + + // Try to create another instance with the same ID but dedupe statuses does NOT include Completed + // This should succeed (replace the existing instance) + string secondInstanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + OrchestrationName, + input: false, + new StartOrchestrationOptions(instanceId).WithDedupeStatuses( + OrchestrationRuntimeStatus.Failed, + OrchestrationRuntimeStatus.Terminated)); + + secondInstanceId.Should().Be(instanceId); + + // Wait for the new instance to start running + await server.Client.WaitForInstanceStartAsync(instanceId, default); + + // Verify the new instance is running + OrchestrationMetadata? newMetadata = await server.Client.GetInstanceAsync(instanceId, false); + newMetadata.Should().NotBeNull(); + newMetadata!.RuntimeStatus.Should().Be(OrchestrationRuntimeStatus.Running); + } + + [Fact] + public async Task ScheduleNewOrchestrationInstance_WithDedupeStatuses_ThrowsWhenInstanceIsFailed() + { + await using HostTestLifetime server = await this.StartAsync(); + + string instanceId = "dedup-test-instance-failed"; + + // Create first orchestration instance that will fail + string firstInstanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + OrchestrationName, + input: true, // true means it will throw + new StartOrchestrationOptions(instanceId)); + + // Wait for it to fail + await server.Client.WaitForInstanceStartAsync(firstInstanceId, default); + await server.Client.RaiseEventAsync(firstInstanceId, "event", default); + await server.Client.WaitForInstanceCompletionAsync(firstInstanceId, default); + + // Verify it's failed + OrchestrationMetadata? metadata = await server.Client.GetInstanceAsync(instanceId, false); + metadata.Should().NotBeNull(); + metadata!.RuntimeStatus.Should().Be(OrchestrationRuntimeStatus.Failed); + + // Try to create another instance with the same ID and dedupe statuses including Failed + // This should throw an exception + Func createAction = () => server.Client.ScheduleNewOrchestrationInstanceAsync( + OrchestrationName, + input: false, + new StartOrchestrationOptions(instanceId).WithDedupeStatuses(OrchestrationRuntimeStatus.Failed)); + + await createAction.Should().ThrowAsync() + .Where(e => e.StatusCode == StatusCode.AlreadyExists); + } + + [Fact] + public async Task ScheduleNewOrchestrationInstance_WithDedupeStatuses_AllowsCreationWhenInstanceDoesNotExist() + { + await using HostTestLifetime server = await this.StartAsync(); + + string instanceId = "dedup-test-instance-new"; + + // Create instance with dedupe statuses - should succeed since instance doesn't exist + string createdInstanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + OrchestrationName, + input: false, + new StartOrchestrationOptions(instanceId).WithDedupeStatuses( + OrchestrationRuntimeStatus.Completed, + OrchestrationRuntimeStatus.Failed)); + + createdInstanceId.Should().Be(instanceId); + + // Verify the instance was created + OrchestrationMetadata? metadata = await server.Client.GetInstanceAsync(instanceId, false); + metadata.Should().NotBeNull(); + metadata!.RuntimeStatus.Should().Be(OrchestrationRuntimeStatus.Running); + } + + [Fact] + public async Task ScheduleNewOrchestrationInstance_WithMultipleDedupeStatuses_ThrowsWhenAnyStatusMatches() + { + await using HostTestLifetime server = await this.StartAsync(); + + string instanceId = "dedup-test-instance-multiple"; + + // Create first orchestration instance + string firstInstanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + OrchestrationName, + input: false, + new StartOrchestrationOptions(instanceId)); + + // Wait for it to complete + await server.Client.WaitForInstanceStartAsync(firstInstanceId, default); + await server.Client.RaiseEventAsync(firstInstanceId, "event", default); + await server.Client.WaitForInstanceCompletionAsync(firstInstanceId, default); + + // Verify it's completed + OrchestrationMetadata? metadata = await server.Client.GetInstanceAsync(instanceId, false); + metadata.Should().NotBeNull(); + metadata!.RuntimeStatus.Should().Be(OrchestrationRuntimeStatus.Completed); + + // Try to create another instance with multiple dedupe statuses including Completed + // This should throw an exception + Func createAction = () => server.Client.ScheduleNewOrchestrationInstanceAsync( + OrchestrationName, + input: false, + new StartOrchestrationOptions(instanceId).WithDedupeStatuses( + OrchestrationRuntimeStatus.Completed, + OrchestrationRuntimeStatus.Failed, + OrchestrationRuntimeStatus.Terminated)); + + await createAction.Should().ThrowAsync() + .Where(e => e.StatusCode == StatusCode.AlreadyExists); + } + + [Fact] + public async Task SuspendAndResumeInstance_EndToEnd() + { + // Arrange + await using HostTestLifetime server = await this.StartLongRunningAsync(); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + OrchestrationName, input: false); + + // Wait for the orchestration to start + await server.Client.WaitForInstanceStartAsync(instanceId, default); + + // Act - Suspend the orchestration + await server.Client.SuspendInstanceAsync(instanceId, "Test suspension", default); + + // Poll for suspended status + OrchestrationMetadata? suspendedMetadata = await this.PollForStatusAsync( + server.Client, instanceId, OrchestrationRuntimeStatus.Suspended, default); + + // Assert - Verify orchestration is suspended + suspendedMetadata.Should().NotBeNull(); + suspendedMetadata!.RuntimeStatus.Should().Be(OrchestrationRuntimeStatus.Suspended); + suspendedMetadata.InstanceId.Should().Be(instanceId); + + // Act - Resume the orchestration + await server.Client.ResumeInstanceAsync(instanceId, "Test resumption", default); + + // Poll for running status + OrchestrationMetadata? resumedMetadata = await this.PollForStatusAsync( + server.Client, instanceId, OrchestrationRuntimeStatus.Running, default); + + // Assert - Verify orchestration is running again + resumedMetadata.Should().NotBeNull(); + resumedMetadata!.RuntimeStatus.Should().Be(OrchestrationRuntimeStatus.Running); + + // Complete the orchestration + await server.Client.RaiseEventAsync(instanceId, "event", default); + await server.Client.WaitForInstanceCompletionAsync(instanceId, default); + + // Verify the orchestration completed successfully + OrchestrationMetadata? completedMetadata = await server.Client.GetInstanceAsync(instanceId, false); + completedMetadata.Should().NotBeNull(); + completedMetadata!.RuntimeStatus.Should().Be(OrchestrationRuntimeStatus.Completed); + } + + [Fact] + public async Task SuspendInstance_WithoutReason_Succeeds() + { + // Arrange + await using HostTestLifetime server = await this.StartLongRunningAsync(); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + OrchestrationName, input: false); + + await server.Client.WaitForInstanceStartAsync(instanceId, default); + + // Act - Suspend without a reason + await server.Client.SuspendInstanceAsync(instanceId, cancellation: default); + + // Poll for suspended status + OrchestrationMetadata? suspendedMetadata = await this.PollForStatusAsync( + server.Client, instanceId, OrchestrationRuntimeStatus.Suspended, default); + + // Assert + suspendedMetadata.Should().NotBeNull(); + suspendedMetadata!.RuntimeStatus.Should().Be(OrchestrationRuntimeStatus.Suspended); + } + + [Fact] + public async Task ResumeInstance_WithoutReason_Succeeds() + { + // Arrange + await using HostTestLifetime server = await this.StartLongRunningAsync(); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + OrchestrationName, input: false); + + await server.Client.WaitForInstanceStartAsync(instanceId, default); + await server.Client.SuspendInstanceAsync(instanceId, "Test suspension", default); + + // Wait for suspension + await this.PollForStatusAsync(server.Client, instanceId, OrchestrationRuntimeStatus.Suspended, default); + + // Act - Resume without a reason + await server.Client.ResumeInstanceAsync(instanceId, cancellation: default); + + // Poll for running status + OrchestrationMetadata? resumedMetadata = await this.PollForStatusAsync( + server.Client, instanceId, OrchestrationRuntimeStatus.Running, default); + + // Assert + resumedMetadata.Should().NotBeNull(); + resumedMetadata!.RuntimeStatus.Should().Be(OrchestrationRuntimeStatus.Running); + } + + [Fact] + public async Task SuspendInstance_AlreadyCompleted_NoError() + { + // Arrange + await using HostTestLifetime server = await this.StartAsync(); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + OrchestrationName, input: false); + + await server.Client.WaitForInstanceStartAsync(instanceId, default); + await server.Client.RaiseEventAsync(instanceId, "event", default); + await server.Client.WaitForInstanceCompletionAsync(instanceId, default); + + // Verify it's completed + OrchestrationMetadata? completedMetadata = await server.Client.GetInstanceAsync(instanceId, false); + completedMetadata.Should().NotBeNull(); + completedMetadata!.RuntimeStatus.Should().Be(OrchestrationRuntimeStatus.Completed); + + // Act - Try to suspend a completed orchestration (should not throw) + await server.Client.SuspendInstanceAsync(instanceId, "Test suspension", default); + + // Assert - Status should remain completed + OrchestrationMetadata? metadata = await server.Client.GetInstanceAsync(instanceId, false); + metadata.Should().NotBeNull(); + metadata!.RuntimeStatus.Should().Be(OrchestrationRuntimeStatus.Completed); + } + + [Fact] + public async Task ResumeInstance_NotSuspended_NoError() + { + // Arrange + await using HostTestLifetime server = await this.StartLongRunningAsync(); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync( + OrchestrationName, input: false); + + await server.Client.WaitForInstanceStartAsync(instanceId, default); + + // Verify it's running + OrchestrationMetadata? runningMetadata = await server.Client.GetInstanceAsync(instanceId, false); + runningMetadata.Should().NotBeNull(); + runningMetadata!.RuntimeStatus.Should().Be(OrchestrationRuntimeStatus.Running); + + // Act - Try to resume an already running orchestration (should not throw) + await server.Client.ResumeInstanceAsync(instanceId, "Test resumption", default); + + // Assert - Status should remain running + OrchestrationMetadata? metadata = await server.Client.GetInstanceAsync(instanceId, false); + metadata.Should().NotBeNull(); + metadata!.RuntimeStatus.Should().Be(OrchestrationRuntimeStatus.Running); + } + Task StartAsync() { static async Task Orchestration(TaskOrchestrationContext context, bool shouldThrow) @@ -308,6 +631,56 @@ static async Task Orchestration(TaskOrchestrationContext context, bool s }); } + Task StartLongRunningAsync() + { + static async Task LongRunningOrchestration(TaskOrchestrationContext context, bool shouldThrow) + { + context.SetCustomStatus("waiting"); + // Wait for external event or a timer (30 seconds) to allow suspend/resume operations + Task eventTask = context.WaitForExternalEvent("event"); + Task timerTask = context.CreateTimer(TimeSpan.FromSeconds(30), CancellationToken.None); + Task completedTask = await Task.WhenAny(eventTask, timerTask); + + if (completedTask == timerTask) + { + throw new TimeoutException("Timed out waiting for external event 'event'."); + } + + if (shouldThrow) + { + throw new InvalidOperationException("Orchestration failed"); + } + + return $"{shouldThrow} -> output"; + } + + return this.StartWorkerAsync(b => + { + b.AddTasks(tasks => tasks.AddOrchestratorFunc(OrchestrationName, LongRunningOrchestration)); + }); + } + + async Task PollForStatusAsync( + DurableTaskClient client, + string instanceId, + OrchestrationRuntimeStatus expectedStatus, + CancellationToken cancellation = default) + { + DateTime deadline = DateTime.UtcNow.AddSeconds(PollingTimeoutSeconds); + while (DateTime.UtcNow < deadline) + { + OrchestrationMetadata? metadata = await client.GetInstanceAsync(instanceId, false, cancellation); + if (metadata?.RuntimeStatus == expectedStatus) + { + return metadata; + } + + await Task.Delay(TimeSpan.FromMilliseconds(PollingIntervalMilliseconds), cancellation); + } + + return await client.GetInstanceAsync(instanceId, false, cancellation); + } + class DateTimeToleranceComparer : IEqualityComparer { public bool Equals(DateTimeOffset x, DateTimeOffset y) => (x - y).Duration() < TimeSpan.FromMilliseconds(100); diff --git a/test/Grpc.IntegrationTests/OrchestrationPatterns.cs b/test/Grpc.IntegrationTests/OrchestrationPatterns.cs index 72c286f38..be7c0d1dc 100644 --- a/test/Grpc.IntegrationTests/OrchestrationPatterns.cs +++ b/test/Grpc.IntegrationTests/OrchestrationPatterns.cs @@ -36,7 +36,7 @@ public async Task EmptyOrchestration() } [Fact] - public async Task ScheduleOrchesrationWithTags() + public async Task ScheduleOrchestrationWithTags() { TaskName orchestratorName = nameof(EmptyOrchestration); await using HostTestLifetime server = await this.StartWorkerAsync(b => @@ -67,6 +67,52 @@ public async Task ScheduleOrchesrationWithTags() Assert.Equal("value2", metadata.Tags["tag2"]); } + [Fact] + public async Task ScheduleSubOrchestrationWithTags() + { + TaskName orchestratorName = nameof(ScheduleSubOrchestrationWithTags); + + // Schedule a new orchestration instance with tags + SubOrchestrationOptions subOrchestrationOptions = new() + { + InstanceId = "instance_id", + Tags = new Dictionary + { + { "tag1", "value1" }, + { "tag2", "value2" } + } + }; + + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => tasks.AddOrchestratorFunc(orchestratorName, async (ctx, input) => + { + int result = 1; + if (input < 2) + { + // recursively call this same orchestrator + result += await ctx.CallSubOrchestratorAsync(orchestratorName, input: input + 1, subOrchestrationOptions); + } + + return result; + })); + }); + + + await server.Client.ScheduleNewOrchestrationInstanceAsync(orchestratorName, input: 1); + + OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( + subOrchestrationOptions.InstanceId, this.TimeoutToken); + + Assert.NotNull(metadata); + Assert.Equal(subOrchestrationOptions.InstanceId, metadata.InstanceId); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.NotNull(metadata.Tags); + Assert.Equal(2, metadata.Tags.Count); + Assert.Equal("value1", metadata.Tags["tag1"]); + Assert.Equal("value2", metadata.Tags["tag2"]); + } + [Fact] public async Task SingleTimer() {