From e9aaa7e9734461fe878bc64e501dc465355950f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 7 Dec 2025 03:00:37 +0000 Subject: [PATCH 1/5] Initial plan From b7fc1ab949ff3c064e3916da71fae0415fdd9ad6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 7 Dec 2025 03:11:49 +0000 Subject: [PATCH 2/5] Implement fix for JsonElement deserialization issue with Dictionary Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com> --- .../Converters/JsonDataConverter.cs | 1 + .../ObjectToInferredTypesConverter.cs | 83 +++++++ .../JsonDataConverterTests.cs | 202 ++++++++++++++++++ .../OrchestrationPatterns.cs | 77 +++++++ 4 files changed, 363 insertions(+) create mode 100644 src/Abstractions/Converters/ObjectToInferredTypesConverter.cs create mode 100644 test/Abstractions.Tests/JsonDataConverterTests.cs diff --git a/src/Abstractions/Converters/JsonDataConverter.cs b/src/Abstractions/Converters/JsonDataConverter.cs index eeca67a3..cf17e792 100644 --- a/src/Abstractions/Converters/JsonDataConverter.cs +++ b/src/Abstractions/Converters/JsonDataConverter.cs @@ -14,6 +14,7 @@ public class JsonDataConverter : DataConverter static readonly JsonSerializerOptions DefaultOptions = new() { IncludeFields = true, + Converters = { new ObjectToInferredTypesConverter() }, }; readonly JsonSerializerOptions? options; diff --git a/src/Abstractions/Converters/ObjectToInferredTypesConverter.cs b/src/Abstractions/Converters/ObjectToInferredTypesConverter.cs new file mode 100644 index 00000000..099ab4bf --- /dev/null +++ b/src/Abstractions/Converters/ObjectToInferredTypesConverter.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.DurableTask.Converters; + +/// +/// A custom JSON converter that deserializes JSON tokens into inferred .NET types instead of JsonElement. +/// +/// +/// When deserializing to object type, System.Text.Json defaults to returning JsonElement instances. +/// This converter infers the appropriate .NET type based on the JSON token type: +/// - JSON strings become +/// - JSON numbers become , , or +/// - JSON booleans become +/// - JSON objects become where TValue is +/// - JSON arrays become [] +/// - JSON null becomes null +/// This is particularly useful when working with where TValue is +/// , ensuring that complex types are preserved as dictionaries rather than JsonElement. +/// +internal sealed class ObjectToInferredTypesConverter : JsonConverter +{ + /// + public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.True: + case JsonTokenType.False: + return reader.GetBoolean(); + case JsonTokenType.Number: + if (reader.TryGetInt32(out int intValue)) + { + return intValue; + } + + if (reader.TryGetInt64(out long longValue)) + { + return longValue; + } + + return reader.GetDouble(); + case JsonTokenType.String: + return reader.GetString(); + case JsonTokenType.StartObject: + Dictionary dictionary = new(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected property name."); + } + + string propertyName = reader.GetString() ?? throw new JsonException("Property name cannot be null."); + reader.Read(); + dictionary[propertyName] = this.Read(ref reader, typeof(object), options); + } + + return dictionary; + case JsonTokenType.StartArray: + List list = new(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + list.Add(this.Read(ref reader, typeof(object), options)); + } + + return list.ToArray(); + case JsonTokenType.Null: + return null; + default: + throw new JsonException($"Unsupported JSON token type: {reader.TokenType}"); + } + } + + /// + public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, value?.GetType() ?? typeof(object), options); + } +} diff --git a/test/Abstractions.Tests/JsonDataConverterTests.cs b/test/Abstractions.Tests/JsonDataConverterTests.cs new file mode 100644 index 00000000..2cde4f76 --- /dev/null +++ b/test/Abstractions.Tests/JsonDataConverterTests.cs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Microsoft.DurableTask.Converters; + +namespace Microsoft.DurableTask.Tests; + +public class JsonDataConverterTests +{ + readonly JsonDataConverter converter = JsonDataConverter.Default; + + [Fact] + public void Serialize_Null_ReturnsNull() + { + // Act + string? result = this.converter.Serialize(null); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void Deserialize_Null_ReturnsNull() + { + // Act + object? result = this.converter.Deserialize(null, typeof(object)); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void RoundTrip_SimpleTypes_PreservesTypes() + { + // Arrange + Dictionary input = new() + { + { "string", "value" }, + { "int", 42 }, + { "long", 9223372036854775807L }, + { "double", 3.14 }, + { "bool", true }, + { "null", null! }, + }; + + // Act + string serialized = this.converter.Serialize(input)!; + Dictionary? result = this.converter.Deserialize>(serialized); + + // Assert + result.Should().NotBeNull(); + result!["string"].Should().BeOfType().And.Be("value"); + result["int"].Should().BeOfType().And.Be(42); + + // Note: Large integers that don't fit in int32 will be deserialized as long + result["long"].Should().Match(o => o is long || o is double); + + result["double"].Should().BeOfType().And.Be(3.14); + result["bool"].Should().BeOfType().And.Be(true); + result["null"].Should().BeNull(); + } + + [Fact] + public void RoundTrip_NestedDictionary_PreservesStructure() + { + // Arrange + Dictionary input = new() + { + { + "nested", new Dictionary + { + { "key1", "value1" }, + { "key2", 123 }, + } + }, + }; + + // Act + string serialized = this.converter.Serialize(input)!; + Dictionary? result = this.converter.Deserialize>(serialized); + + // Assert + result.Should().NotBeNull(); + result!["nested"].Should().BeOfType>(); + Dictionary nested = (Dictionary)result["nested"]; + nested["key1"].Should().BeOfType().And.Be("value1"); + nested["key2"].Should().BeOfType().And.Be(123); + } + + [Fact] + public void RoundTrip_Array_PreservesElements() + { + // Arrange + Dictionary input = new() + { + { "array", new object[] { "string", 42, true } }, + }; + + // Act + string serialized = this.converter.Serialize(input)!; + Dictionary? result = this.converter.Deserialize>(serialized); + + // Assert + result.Should().NotBeNull(); + result!["array"].Should().BeOfType(); + object[] array = (object[])result["array"]; + array.Should().HaveCount(3); + array[0].Should().BeOfType().And.Be("string"); + array[1].Should().BeOfType().And.Be(42); + array[2].Should().BeOfType().And.Be(true); + } + + [Fact] + public void RoundTrip_ComplexObject_PreservesStructure() + { + // Arrange - simulate the issue described in the GitHub issue + Dictionary input = new() + { + { "ComponentContext", new { Name = "TestComponent", Id = 123 } }, + { "PlanResult", new { Status = "Success", Count = 5 } }, + }; + + // Act + string serialized = this.converter.Serialize(input)!; + Dictionary? result = this.converter.Deserialize>(serialized); + + // Assert + result.Should().NotBeNull(); + + // The anonymous objects should be deserialized as dictionaries, not JsonElements + result!["ComponentContext"].Should().BeOfType>(); + Dictionary componentContext = (Dictionary)result["ComponentContext"]; + componentContext["Name"].Should().BeOfType().And.Be("TestComponent"); + componentContext["Id"].Should().BeOfType().And.Be(123); + + result["PlanResult"].Should().BeOfType>(); + Dictionary planResult = (Dictionary)result["PlanResult"]; + planResult["Status"].Should().BeOfType().And.Be("Success"); + planResult["Count"].Should().BeOfType().And.Be(5); + } + + [Fact] + public void Deserialize_JsonWithoutConverter_ProducesJsonElements() + { + // Arrange - use standard System.Text.Json without our custom converter + JsonSerializerOptions standardOptions = new() { IncludeFields = true }; + Dictionary input = new() + { + { "key", "value" }, + }; + string json = JsonSerializer.Serialize(input, standardOptions); + + // Act + Dictionary? result = JsonSerializer.Deserialize>(json, standardOptions); + + // Assert - without our converter, values are JsonElements + result.Should().NotBeNull(); + result!["key"].Should().BeOfType(); + } + + [Fact] + public void Deserialize_JsonWithConverter_ProducesConcreteTypes() + { + // Arrange + Dictionary input = new() + { + { "key", "value" }, + }; + string json = this.converter.Serialize(input)!; + + // Act + Dictionary? result = this.converter.Deserialize>(json); + + // Assert - with our converter, values are concrete types + result.Should().NotBeNull(); + result!["key"].Should().BeOfType().And.Be("value"); + } + + [Fact] + public void RoundTrip_RecordType_PreservesProperties() + { + // Arrange + TestRecord input = new("TestValue", 42, new Dictionary + { + { "nested", "data" }, + }); + + // Act + string serialized = this.converter.Serialize(input)!; + TestRecord? result = this.converter.Deserialize(serialized); + + // Assert + result.Should().NotBeNull(); + result!.Name.Should().Be("TestValue"); + result.Value.Should().Be(42); + result.Properties.Should().ContainKey("nested"); + result.Properties["nested"].Should().BeOfType().And.Be("data"); + } + + record TestRecord(string Name, int Value, Dictionary Properties); +} diff --git a/test/Grpc.IntegrationTests/OrchestrationPatterns.cs b/test/Grpc.IntegrationTests/OrchestrationPatterns.cs index 666d7e25..4e810715 100644 --- a/test/Grpc.IntegrationTests/OrchestrationPatterns.cs +++ b/test/Grpc.IntegrationTests/OrchestrationPatterns.cs @@ -1197,4 +1197,81 @@ async Task OrchestratorFunc(TaskOrchestrationContext ctx, int counter) // TODO: Test for multiple external events with the same name // TODO: Test for catching activity exceptions of specific types + + [Fact] + public async Task ActivityInput_ComplexTypesInDictionary_PreservesTypes() + { + // This test verifies the fix for: https://github.com/microsoft/durabletask-dotnet/issues/XXX + // Previously, when passing Dictionary to activities, the object values + // would be deserialized as JsonElement instead of their original types. + TaskName orchestratorName = "ComplexInputOrchestrator"; + TaskName activityName = "ComplexInputActivity"; + + Dictionary capturedInput = null!; + + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => tasks + .AddOrchestratorFunc(orchestratorName, async (ctx, _) => + { + Dictionary complexInput = new() + { + { "StringValue", "test" }, + { "IntValue", 42 }, + { "BoolValue", true }, + { "NestedObject", new { Name = "TestObject", Id = 123 } }, + { "ArrayValue", new object[] { "item1", 2, false } }, + }; + + return await ctx.CallActivityAsync(activityName, complexInput); + }) + .AddActivityFunc, string>(activityName, (ctx, input) => + { + capturedInput = input; + return Task.FromResult("success"); + })); + }); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync(orchestratorName); + OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, this.TimeoutToken); + + Assert.NotNull(metadata); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + Assert.Equal("success", metadata.ReadOutputAs()); + + // Verify that the input was correctly deserialized with concrete types, not JsonElement + Assert.NotNull(capturedInput); + + // String should remain a string + Assert.IsType(capturedInput["StringValue"]); + Assert.Equal("test", capturedInput["StringValue"]); + + // Int should remain an int + Assert.IsType(capturedInput["IntValue"]); + Assert.Equal(42, capturedInput["IntValue"]); + + // Bool should remain a bool + Assert.IsType(capturedInput["BoolValue"]); + Assert.Equal(true, capturedInput["BoolValue"]); + + // Nested object should be a Dictionary, not JsonElement + Assert.IsType>(capturedInput["NestedObject"]); + Dictionary nestedObject = (Dictionary)capturedInput["NestedObject"]; + Assert.IsType(nestedObject["Name"]); + Assert.Equal("TestObject", nestedObject["Name"]); + Assert.IsType(nestedObject["Id"]); + Assert.Equal(123, nestedObject["Id"]); + + // Array should be object[], not JsonElement + Assert.IsType(capturedInput["ArrayValue"]); + object[] arrayValue = (object[])capturedInput["ArrayValue"]; + Assert.Equal(3, arrayValue.Length); + Assert.IsType(arrayValue[0]); + Assert.Equal("item1", arrayValue[0]); + Assert.IsType(arrayValue[1]); + Assert.Equal(2, arrayValue[1]); + Assert.IsType(arrayValue[2]); + Assert.Equal(false, arrayValue[2]); + } } From 35e6fc410aac069e327bcb2cfa2b239eaab889ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 7 Dec 2025 03:13:13 +0000 Subject: [PATCH 3/5] Address code review comment - remove placeholder URL Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com> --- test/Grpc.IntegrationTests/OrchestrationPatterns.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Grpc.IntegrationTests/OrchestrationPatterns.cs b/test/Grpc.IntegrationTests/OrchestrationPatterns.cs index 4e810715..ea3f387d 100644 --- a/test/Grpc.IntegrationTests/OrchestrationPatterns.cs +++ b/test/Grpc.IntegrationTests/OrchestrationPatterns.cs @@ -1201,9 +1201,9 @@ async Task OrchestratorFunc(TaskOrchestrationContext ctx, int counter) [Fact] public async Task ActivityInput_ComplexTypesInDictionary_PreservesTypes() { - // This test verifies the fix for: https://github.com/microsoft/durabletask-dotnet/issues/XXX // Previously, when passing Dictionary to activities, the object values // would be deserialized as JsonElement instead of their original types. + // This test verifies that complex types are now properly preserved. TaskName orchestratorName = "ComplexInputOrchestrator"; TaskName activityName = "ComplexInputActivity"; From 405b814d3b5a83aac240c97310ba3374bd32d00e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 7 Dec 2025 04:25:29 +0000 Subject: [PATCH 4/5] Add comprehensive tests for custom types with object fields and arrays Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com> --- .../JsonDataConverterTests.cs | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/test/Abstractions.Tests/JsonDataConverterTests.cs b/test/Abstractions.Tests/JsonDataConverterTests.cs index 2cde4f76..2251c792 100644 --- a/test/Abstractions.Tests/JsonDataConverterTests.cs +++ b/test/Abstractions.Tests/JsonDataConverterTests.cs @@ -198,5 +198,110 @@ public void RoundTrip_RecordType_PreservesProperties() result.Properties["nested"].Should().BeOfType().And.Be("data"); } + [Fact] + public void RoundTrip_CustomTypeWithObjectField_PreservesType() + { + // Arrange - custom type with object field + CustomTypeWithObjectField input = new() + { + Name = "Test", + Data = new { Setting = "value", Count = 10 }, + }; + + // Act + string serialized = this.converter.Serialize(input)!; + CustomTypeWithObjectField? result = this.converter.Deserialize(serialized); + + // Assert + result.Should().NotBeNull(); + result!.Name.Should().Be("Test"); + + // The object field should be deserialized as Dictionary + result.Data.Should().BeOfType>(); + Dictionary data = (Dictionary)result.Data!; + data["Setting"].Should().BeOfType().And.Be("value"); + data["Count"].Should().BeOfType().And.Be(10); + } + + [Fact] + public void RoundTrip_CustomTypeWithObjectArray_PreservesTypes() + { + // Arrange + CustomTypeWithObjectArray input = new() + { + Items = new object[] { "string", 42, true, new { Nested = "value" } }, + }; + + // Act + string serialized = this.converter.Serialize(input)!; + CustomTypeWithObjectArray? result = this.converter.Deserialize(serialized); + + // Assert + result.Should().NotBeNull(); + result!.Items.Should().NotBeNull().And.HaveCount(4); + result.Items![0].Should().BeOfType().And.Be("string"); + result.Items[1].Should().BeOfType().And.Be(42); + result.Items[2].Should().BeOfType().And.Be(true); + result.Items[3].Should().BeOfType>(); + + Dictionary nested = (Dictionary)result.Items[3]; + nested["Nested"].Should().BeOfType().And.Be("value"); + } + + [Fact] + public void RoundTrip_DeeplyNestedCustomTypes_PreservesStructure() + { + // Arrange - simulate complex activity input with multiple levels of nesting + ActivityInput input = new() + { + Metadata = new Dictionary + { + { "ComponentContext", new { Name = "loganalytics", Config = new { Setting = "value" } } }, + { "PlanResult", new { Success = true, Items = new[] { "item1", "item2" } } }, + }, + }; + + // Act + string serialized = this.converter.Serialize(input)!; + ActivityInput? result = this.converter.Deserialize(serialized); + + // Assert + result.Should().NotBeNull(); + result!.Metadata.Should().HaveCount(2); + + // Check ComponentContext + result.Metadata["ComponentContext"].Should().BeOfType>(); + Dictionary component = (Dictionary)result.Metadata["ComponentContext"]; + component["Name"].Should().BeOfType().And.Be("loganalytics"); + component["Config"].Should().BeOfType>(); + Dictionary config = (Dictionary)component["Config"]; + config["Setting"].Should().BeOfType().And.Be("value"); + + // Check PlanResult + result.Metadata["PlanResult"].Should().BeOfType>(); + Dictionary plan = (Dictionary)result.Metadata["PlanResult"]; + plan["Success"].Should().BeOfType().And.Be(true); + plan["Items"].Should().BeOfType(); + object[] items = (object[])plan["Items"]; + items[0].Should().BeOfType().And.Be("item1"); + items[1].Should().BeOfType().And.Be("item2"); + } + record TestRecord(string Name, int Value, Dictionary Properties); + + class CustomTypeWithObjectField + { + public string Name { get; set; } = ""; + public object? Data { get; set; } + } + + class CustomTypeWithObjectArray + { + public object[]? Items { get; set; } + } + + class ActivityInput + { + public Dictionary Metadata { get; set; } = new(); + } } From 95e748885be00a22dd6b5e6002891a0a3a69aca4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:33:54 +0000 Subject: [PATCH 5/5] Merge main branch to resolve conflicts Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com> --- README.md | 2 +- .../Activities/FunctionNotFoundAnalyzer.cs | 294 ++++++++++++++ src/Analyzers/AnalyzerReleases.Shipped.md | 9 + src/Analyzers/AnalyzerReleases.Unshipped.md | 7 +- src/Analyzers/Analyzers.csproj | 2 +- src/Analyzers/Resources.resx | 12 + .../PayloadStore/BlobPayloadStore.cs | 34 +- src/Generators/DurableTaskSourceGenerator.cs | 14 + src/Generators/Generators.csproj | 1 - .../FunctionNotFoundAnalyzerTests.cs | 374 ++++++++++++++++++ test/Generators.Tests/AzureFunctionsTests.cs | 38 ++ .../Generators.Tests/ClassBasedSyntaxTests.cs | 38 ++ .../OrchestrationPatterns.cs | 99 ++++- 13 files changed, 908 insertions(+), 16 deletions(-) create mode 100644 src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs create mode 100644 test/Analyzers.Tests/Activities/FunctionNotFoundAnalyzerTests.cs diff --git a/README.md b/README.md index 35c4386d..7226f201 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ To get started, add the [Microsoft.Azure.Functions.Worker.Extensions.DurableTask - + ``` diff --git a/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs b/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs new file mode 100644 index 00000000..b1222d4b --- /dev/null +++ b/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs @@ -0,0 +1,294 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Microsoft.DurableTask.Analyzers.Activities; + +/// +/// Analyzer that detects calls to non-existent activities and sub-orchestrations. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class FunctionNotFoundAnalyzer : DiagnosticAnalyzer +{ + /// + /// The diagnostic ID for the diagnostic that reports when an activity call references a function that doesn't exist. + /// + public const string ActivityNotFoundDiagnosticId = "DURABLE2003"; + + /// + /// The diagnostic ID for the diagnostic that reports when a sub-orchestration call references a function that doesn't exist. + /// + public const string SubOrchestrationNotFoundDiagnosticId = "DURABLE2004"; + + static readonly LocalizableString ActivityNotFoundTitle = new LocalizableResourceString(nameof(Resources.ActivityNotFoundAnalyzerTitle), Resources.ResourceManager, typeof(Resources)); + static readonly LocalizableString ActivityNotFoundMessageFormat = new LocalizableResourceString(nameof(Resources.ActivityNotFoundAnalyzerMessageFormat), Resources.ResourceManager, typeof(Resources)); + + static readonly LocalizableString SubOrchestrationNotFoundTitle = new LocalizableResourceString(nameof(Resources.SubOrchestrationNotFoundAnalyzerTitle), Resources.ResourceManager, typeof(Resources)); + static readonly LocalizableString SubOrchestrationNotFoundMessageFormat = new LocalizableResourceString(nameof(Resources.SubOrchestrationNotFoundAnalyzerMessageFormat), Resources.ResourceManager, typeof(Resources)); + + static readonly DiagnosticDescriptor ActivityNotFoundRule = new( + ActivityNotFoundDiagnosticId, + ActivityNotFoundTitle, + ActivityNotFoundMessageFormat, + AnalyzersCategories.Activity, + DiagnosticSeverity.Warning, + customTags: [WellKnownDiagnosticTags.CompilationEnd], + isEnabledByDefault: true); + + static readonly DiagnosticDescriptor SubOrchestrationNotFoundRule = new( + SubOrchestrationNotFoundDiagnosticId, + SubOrchestrationNotFoundTitle, + SubOrchestrationNotFoundMessageFormat, + AnalyzersCategories.Orchestration, + DiagnosticSeverity.Warning, + customTags: [WellKnownDiagnosticTags.CompilationEnd], + isEnabledByDefault: true); + + /// + public override ImmutableArray SupportedDiagnostics => [ActivityNotFoundRule, SubOrchestrationNotFoundRule]; + + /// + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(context => + { + KnownTypeSymbols knownSymbols = new(context.Compilation); + + if (knownSymbols.TaskOrchestrationContext == null || + knownSymbols.Task == null || knownSymbols.TaskT == null) + { + // Core symbols not available in this compilation, skip analysis + return; + } + + // Activity-related symbols (may be null if activities aren't used) + IMethodSymbol? taskActivityRunAsync = knownSymbols.TaskActivityBase?.GetMembers("RunAsync").OfType().SingleOrDefault(); + + // Search for Activity and Sub-Orchestrator invocations + ConcurrentBag activityInvocations = []; + ConcurrentBag subOrchestrationInvocations = []; + + context.RegisterOperationAction( + ctx => + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + + if (ctx.Operation is not IInvocationOperation invocationOperation) + { + return; + } + + IMethodSymbol targetMethod = invocationOperation.TargetMethod; + + // Check for CallActivityAsync + if (targetMethod.IsEqualTo(knownSymbols.TaskOrchestrationContext, "CallActivityAsync")) + { + string? activityName = ExtractFunctionName(invocationOperation, "name", ctx); + if (activityName != null) + { + activityInvocations.Add(new FunctionInvocation(activityName, invocationOperation.Syntax)); + } + } + + // Check for CallSubOrchestratorAsync + if (targetMethod.IsEqualTo(knownSymbols.TaskOrchestrationContext, "CallSubOrchestratorAsync")) + { + string? orchestratorName = ExtractFunctionName(invocationOperation, "orchestratorName", ctx); + if (orchestratorName != null) + { + subOrchestrationInvocations.Add(new FunctionInvocation(orchestratorName, invocationOperation.Syntax)); + } + } + }, + OperationKind.Invocation); + + // Search for Activity definitions + ConcurrentBag activityNames = []; + ConcurrentBag orchestratorNames = []; + + // Search for Durable Functions Activities and Orchestrators definitions (via [Function] attribute) + context.RegisterSymbolAction( + ctx => + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + + if (ctx.Symbol is not IMethodSymbol methodSymbol) + { + return; + } + + // Check for Activity defined via [ActivityTrigger] + if (knownSymbols.ActivityTriggerAttribute != null && + methodSymbol.ContainsAttributeInAnyMethodArguments(knownSymbols.ActivityTriggerAttribute) && + knownSymbols.FunctionNameAttribute != null && + methodSymbol.TryGetSingleValueFromAttribute(knownSymbols.FunctionNameAttribute, out string functionName)) + { + activityNames.Add(functionName); + } + + // Check for Orchestrator defined via [OrchestrationTrigger] + if (knownSymbols.FunctionOrchestrationAttribute != null && + methodSymbol.ContainsAttributeInAnyMethodArguments(knownSymbols.FunctionOrchestrationAttribute) && + knownSymbols.FunctionNameAttribute != null && + methodSymbol.TryGetSingleValueFromAttribute(knownSymbols.FunctionNameAttribute, out string orchestratorFunctionName)) + { + orchestratorNames.Add(orchestratorFunctionName); + } + }, + SymbolKind.Method); + + // Search for TaskActivity definitions (class-based syntax) + context.RegisterSyntaxNodeAction( + ctx => + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + + if (ctx.ContainingSymbol is not INamedTypeSymbol classSymbol) + { + return; + } + + if (classSymbol.IsAbstract) + { + return; + } + + // Check for TaskActivity derived classes + if (knownSymbols.TaskActivityBase != null && taskActivityRunAsync != null && + ClassOverridesMethod(classSymbol, taskActivityRunAsync)) + { + activityNames.Add(classSymbol.Name); + } + + // Check for ITaskOrchestrator implementations (class-based orchestrators) + if (knownSymbols.TaskOrchestratorInterface != null && + classSymbol.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i, knownSymbols.TaskOrchestratorInterface))) + { + orchestratorNames.Add(classSymbol.Name); + } + }, + SyntaxKind.ClassDeclaration); + + // Search for Func/Action activities directly registered through DurableTaskRegistry + context.RegisterOperationAction( + ctx => + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + + if (ctx.Operation is not IInvocationOperation invocation) + { + return; + } + + if (knownSymbols.DurableTaskRegistry == null || + !SymbolEqualityComparer.Default.Equals(invocation.Type, knownSymbols.DurableTaskRegistry)) + { + return; + } + + // Handle AddActivityFunc registrations + if (invocation.TargetMethod.Name == "AddActivityFunc") + { + string? name = ExtractFunctionName(invocation, "name", ctx); + if (name != null) + { + activityNames.Add(name); + } + } + + // Handle AddOrchestratorFunc registrations + if (invocation.TargetMethod.Name == "AddOrchestratorFunc") + { + string? name = ExtractFunctionName(invocation, "name", ctx); + if (name != null) + { + orchestratorNames.Add(name); + } + } + }, + OperationKind.Invocation); + + // At the end of the compilation, we correlate the invocations with the definitions + context.RegisterCompilationEndAction(ctx => + { + // Create lookup sets for faster searching + HashSet definedActivities = new(activityNames); + HashSet definedOrchestrators = new(orchestratorNames); + + // Report diagnostics for activities not found + foreach (FunctionInvocation invocation in activityInvocations.Where(i => !definedActivities.Contains(i.Name))) + { + Diagnostic diagnostic = RoslynExtensions.BuildDiagnostic( + ActivityNotFoundRule, invocation.InvocationSyntaxNode, invocation.Name); + ctx.ReportDiagnostic(diagnostic); + } + + // Report diagnostics for sub-orchestrators not found + foreach (FunctionInvocation invocation in subOrchestrationInvocations.Where(i => !definedOrchestrators.Contains(i.Name))) + { + Diagnostic diagnostic = RoslynExtensions.BuildDiagnostic( + SubOrchestrationNotFoundRule, invocation.InvocationSyntaxNode, invocation.Name); + ctx.ReportDiagnostic(diagnostic); + } + }); + }); + } + + static string? ExtractFunctionName(IInvocationOperation invocationOperation, string parameterName, OperationAnalysisContext ctx) + { + IArgumentOperation? nameArgumentOperation = invocationOperation.Arguments.SingleOrDefault(a => a.Parameter?.Name == parameterName); + if (nameArgumentOperation == null) + { + return null; + } + + SemanticModel? semanticModel = ctx.Operation.SemanticModel; + if (semanticModel == null) + { + return null; + } + + // extracts the constant value from the argument (e.g.: it can be a nameof, string literal or const field) + Optional constant = semanticModel.GetConstantValue(nameArgumentOperation.Value.Syntax); + if (!constant.HasValue) + { + // not a constant value, we cannot correlate this invocation to an existent function in compile time + return null; + } + + return constant.Value?.ToString(); + } + + static bool ClassOverridesMethod(INamedTypeSymbol classSymbol, IMethodSymbol methodToFind) + { + INamedTypeSymbol? baseType = classSymbol; + while (baseType != null) + { + if (baseType.GetMembers().OfType() + .Any(method => SymbolEqualityComparer.Default.Equals(method.OverriddenMethod?.OriginalDefinition, methodToFind))) + { + return true; + } + + baseType = baseType.BaseType; + } + + return false; + } + + readonly struct FunctionInvocation(string name, SyntaxNode invocationSyntaxNode) + { + public string Name { get; } = name; + + public SyntaxNode InvocationSyntaxNode { get; } = invocationSyntaxNode; + } +} diff --git a/src/Analyzers/AnalyzerReleases.Shipped.md b/src/Analyzers/AnalyzerReleases.Shipped.md index c2504981..b3ea2041 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 7d6ac616..d9cf2bd7 100644 --- a/src/Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Analyzers/AnalyzerReleases.Unshipped.md @@ -1,2 +1,7 @@ ; Unshipped analyzer release -; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md \ No newline at end of file +; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- \ No newline at end of file diff --git a/src/Analyzers/Analyzers.csproj b/src/Analyzers/Analyzers.csproj index dad1b98a..be79830c 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/Resources.resx b/src/Analyzers/Resources.resx index 5f8d5219..ee14969a 100644 --- a/src/Analyzers/Resources.resx +++ b/src/Analyzers/Resources.resx @@ -198,4 +198,16 @@ Use '{0}' instead of '{1}' + + The activity function '{0}' was not found in the current compilation. Ensure the activity is defined and the name matches exactly. + + + Activity function not found + + + The sub-orchestration '{0}' was not found in the current compilation. Ensure the orchestrator is defined and the name matches exactly. + + + Sub-orchestration not found + \ No newline at end of file diff --git a/src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs b/src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs index 9edced26..ec66a44b 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/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index 986f7ec3..19a24cc9 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -325,6 +325,9 @@ static void AddOrchestratorFunctionDeclaration(StringBuilder sourceBuilder, Dura static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator) { sourceBuilder.AppendLine($@" + /// + /// Schedules a new instance of the orchestrator. + /// /// public static Task ScheduleNew{orchestrator.TaskName}InstanceAsync( this IOrchestrationSubmitter client, {orchestrator.InputParameter}, StartOrchestrationOptions? options = null) @@ -336,6 +339,9 @@ static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTy static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator) { sourceBuilder.AppendLine($@" + /// + /// Calls the sub-orchestrator. + /// /// public static Task<{orchestrator.OutputType}> Call{orchestrator.TaskName}Async( this TaskOrchestrationContext context, {orchestrator.InputParameter}, TaskOptions? options = null) @@ -347,6 +353,10 @@ static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTas static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo activity) { sourceBuilder.AppendLine($@" + /// + /// Calls the activity. + /// + /// public static Task<{activity.OutputType}> Call{activity.TaskName}Async(this TaskOrchestrationContext ctx, {activity.InputParameter}, TaskOptions? options = null) {{ return ctx.CallActivityAsync<{activity.OutputType}>(""{activity.TaskName}"", input, options); @@ -356,6 +366,10 @@ static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeIn static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableFunction activity) { sourceBuilder.AppendLine($@" + /// + /// Calls the activity. + /// + /// public static Task<{activity.ReturnType}> Call{activity.Name}Async(this TaskOrchestrationContext ctx, {activity.Parameter}, TaskOptions? options = null) {{ return ctx.CallActivityAsync<{activity.ReturnType}>(""{activity.Name}"", {activity.Parameter.Name}, options); diff --git a/src/Generators/Generators.csproj b/src/Generators/Generators.csproj index ff7cffe2..c6149c2b 100644 --- a/src/Generators/Generators.csproj +++ b/src/Generators/Generators.csproj @@ -21,7 +21,6 @@ 1.0.0 - preview.1 diff --git a/test/Analyzers.Tests/Activities/FunctionNotFoundAnalyzerTests.cs b/test/Analyzers.Tests/Activities/FunctionNotFoundAnalyzerTests.cs new file mode 100644 index 00000000..7dff2e38 --- /dev/null +++ b/test/Analyzers.Tests/Activities/FunctionNotFoundAnalyzerTests.cs @@ -0,0 +1,374 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.CodeAnalysis.Testing; +using Microsoft.DurableTask.Analyzers.Activities; +using VerifyCS = Microsoft.DurableTask.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier; + +namespace Microsoft.DurableTask.Analyzers.Tests.Activities; + +public class FunctionNotFoundAnalyzerTests +{ + // ==================== Activity Tests ==================== + + [Fact] + public async Task DurableFunctionActivityInvocationWithMatchingActivity_NoDiagnostic() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + await context.CallActivityAsync(nameof(SayHello), ""Tokyo""); +} + +[Function(nameof(SayHello))] +void SayHello([ActivityTrigger] string name) +{ +} +"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task DurableFunctionActivityInvocationWithMismatchedName_ReportsDiagnostic() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + await {|#0:context.CallActivityAsync(""SayHallo"", ""Tokyo"")|}; +} + +[Function(nameof(SayHello))] +void SayHello([ActivityTrigger] string name) +{ +} +"); + DiagnosticResult expected = BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("SayHallo"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task DurableFunctionActivityInvocationWithNonExistentActivity_ReportsDiagnostic() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + await {|#0:context.CallActivityAsync(""NonExistentActivity"", ""Tokyo"")|}; +} +"); + DiagnosticResult expected = BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("NonExistentActivity"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task TaskActivityInvocationWithMatchingClassBasedActivity_NoDiagnostic() + { + // Arrange + string code = Wrapper.WrapTaskOrchestrator(@" +public class Caller { + async Task Method(TaskOrchestrationContext context) + { + await context.CallActivityAsync(nameof(MyActivity), ""Tokyo""); + } +} + +public class MyActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, string cityName) + { + return Task.FromResult(cityName); + } +} +"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task TaskActivityInvocationWithMismatchedName_ReportsDiagnostic() + { + // Arrange + string code = Wrapper.WrapTaskOrchestrator(@" +public class Caller { + async Task Method(TaskOrchestrationContext context) + { + await {|#0:context.CallActivityAsync(""MyActiviti"", ""Tokyo"")|}; + } +} + +public class MyActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, string cityName) + { + return Task.FromResult(cityName); + } +} +"); + DiagnosticResult expected = BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("MyActiviti"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task LambdaActivityInvocationWithMatchingActivity_NoDiagnostic() + { + // Arrange + string code = Wrapper.WrapFuncOrchestrator(@" +tasks.AddOrchestratorFunc(""HelloSequence"", async context => + await context.CallActivityAsync(""SayHello"", ""Tokyo"")); + +tasks.AddActivityFunc(""SayHello"", (context, city) => { }); +"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task LambdaActivityInvocationWithMismatchedName_ReportsDiagnostic() + { + // Arrange + string code = Wrapper.WrapFuncOrchestrator(@" +tasks.AddOrchestratorFunc(""HelloSequence"", async context => + await {|#0:context.CallActivityAsync(""SayHallo"", ""Tokyo"")|}); + +tasks.AddActivityFunc(""SayHello"", (context, city) => { }); +"); + DiagnosticResult expected = BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("SayHallo"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task ActivityInvocationWithConstVariableName_ReportsDiagnostic() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +const string activityName = ""WrongActivityName""; + +async Task Method(TaskOrchestrationContext context) +{ + await {|#0:context.CallActivityAsync(activityName, ""Tokyo"")|}; +} + +[Function(nameof(SayHello))] +void SayHello([ActivityTrigger] string name) +{ +} +"); + DiagnosticResult expected = BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("WrongActivityName"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task ActivityInvocationWithNonConstVariable_NoDiagnostic() + { + // Arrange - When using a non-const variable, we cannot determine the value at compile-time + // so no diagnostic is reported (to avoid false positives) + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + string activityName = ""NonExistentActivity""; + await context.CallActivityAsync(activityName, ""Tokyo""); +} +"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + // ==================== Sub-Orchestration Tests ==================== + + [Fact] + public async Task SubOrchestrationInvocationWithMatchingOrchestration_NoDiagnostic() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + await context.CallSubOrchestratorAsync(nameof(ChildOrchestration), ""input""); +} + +[Function(nameof(ChildOrchestration))] +async Task ChildOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) +{ + await Task.CompletedTask; +} +"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task SubOrchestrationInvocationWithMismatchedName_ReportsDiagnostic() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + await {|#0:context.CallSubOrchestratorAsync(""ChildOrchestration_WrongName"", ""input"")|}; +} + +[Function(nameof(ChildOrchestration))] +async Task ChildOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) +{ + await Task.CompletedTask; +} +"); + DiagnosticResult expected = BuildSubOrchestrationNotFoundDiagnostic().WithLocation(0).WithArguments("ChildOrchestration_WrongName"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task SubOrchestrationInvocationWithNonExistentOrchestrator_ReportsDiagnostic() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + await {|#0:context.CallSubOrchestratorAsync(""NonExistentOrchestrator"", ""input"")|}; +} +"); + DiagnosticResult expected = BuildSubOrchestrationNotFoundDiagnostic().WithLocation(0).WithArguments("NonExistentOrchestrator"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task SubOrchestrationInvocationWithClassBasedOrchestrator_NoDiagnostic() + { + // Arrange + string code = Wrapper.WrapTaskOrchestrator(@" +public class ParentOrchestration : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string input) + { + await context.CallSubOrchestratorAsync(nameof(ChildOrchestration), ""input""); + return ""done""; + } +} + +public class ChildOrchestration : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, string input) + { + return Task.FromResult(input); + } +} +"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task SubOrchestrationInvocationWithLambdaOrchestrator_NoDiagnostic() + { + // Arrange + string code = Wrapper.WrapFuncOrchestrator(@" +tasks.AddOrchestratorFunc(""ParentOrchestration"", async context => + await context.CallSubOrchestratorAsync(""ChildOrchestration"", ""input"")); + +tasks.AddOrchestratorFunc(""ChildOrchestration"", context => Task.CompletedTask); +"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task SubOrchestrationInvocationWithMismatchedLambdaOrchestrator_ReportsDiagnostic() + { + // Arrange + string code = Wrapper.WrapFuncOrchestrator(@" +tasks.AddOrchestratorFunc(""ParentOrchestration"", async context => + await {|#0:context.CallSubOrchestratorAsync(""ChildOrchestration_WrongName"", ""input"")|}); + +tasks.AddOrchestratorFunc(""ChildOrchestration"", context => Task.CompletedTask); +"); + DiagnosticResult expected = BuildSubOrchestrationNotFoundDiagnostic().WithLocation(0).WithArguments("ChildOrchestration_WrongName"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task SubOrchestrationInvocationWithTypedResult_NoDiagnostic() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + int result = await context.CallSubOrchestratorAsync(nameof(ChildOrchestration), ""input""); +} + +[Function(nameof(ChildOrchestration))] +async Task ChildOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) +{ + return 42; +} +"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task MultipleInvocationsWithSomeMissingFunctions_ReportsMultipleDiagnostics() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + await context.CallActivityAsync(nameof(ExistingActivity), ""input""); + await {|#0:context.CallActivityAsync(""MissingActivity"", ""input"")|}; + await context.CallSubOrchestratorAsync(nameof(ExistingOrchestrator), ""input""); + await {|#1:context.CallSubOrchestratorAsync(""MissingOrchestrator"", ""input"")|}; +} + +[Function(nameof(ExistingActivity))] +void ExistingActivity([ActivityTrigger] string name) { } + +[Function(nameof(ExistingOrchestrator))] +async Task ExistingOrchestrator([OrchestrationTrigger] TaskOrchestrationContext context) +{ + await Task.CompletedTask; +} +"); + DiagnosticResult[] expected = + [ + BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("MissingActivity"), + BuildSubOrchestrationNotFoundDiagnostic().WithLocation(1).WithArguments("MissingOrchestrator") + ]; + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + static DiagnosticResult BuildActivityNotFoundDiagnostic() + { + return VerifyCS.Diagnostic(FunctionNotFoundAnalyzer.ActivityNotFoundDiagnosticId); + } + + static DiagnosticResult BuildSubOrchestrationNotFoundDiagnostic() + { + return VerifyCS.Diagnostic(FunctionNotFoundAnalyzer.SubOrchestrationNotFoundDiagnosticId); + } +} diff --git a/test/Generators.Tests/AzureFunctionsTests.cs b/test/Generators.Tests/AzureFunctionsTests.cs index 6ae9523d..d9d7fad0 100644 --- a/test/Generators.Tests/AzureFunctionsTests.cs +++ b/test/Generators.Tests/AzureFunctionsTests.cs @@ -27,6 +27,10 @@ public class Calculator string expectedOutput = TestHelpers.WrapAndFormat( GeneratedClassName, methodList: @" +/// +/// Calls the activity. +/// +/// public static Task CallIdentityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) { return ctx.CallActivityAsync(""Identity"", input, options); @@ -57,6 +61,10 @@ public class Calculator string expectedOutput = TestHelpers.WrapAndFormat( GeneratedClassName, methodList: @" +/// +/// Calls the activity. +/// +/// public static Task CallIdentityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) { return ctx.CallActivityAsync(""Identity"", input, options); @@ -92,6 +100,10 @@ public class Calculator string expectedOutput = TestHelpers.WrapAndFormat( GeneratedClassName, methodList: @" +/// +/// Calls the activity. +/// +/// public static Task CallIdentityAsync(this TaskOrchestrationContext ctx, AzureFunctionsTests.Input input, TaskOptions? options = null) { return ctx.CallActivityAsync(""Identity"", input, options); @@ -141,6 +153,10 @@ public class MyActivity : TaskActivity<{inputType}, {outputType}> string expectedOutput = TestHelpers.WrapAndFormat( GeneratedClassName, methodList: $@" +/// +/// Calls the activity. +/// +/// public static Task<{outputType}> CallMyActivityAsync(this TaskOrchestrationContext ctx, {inputType} input, TaskOptions? options = null) {{ return ctx.CallActivityAsync<{outputType}>(""MyActivity"", input, options); @@ -214,6 +230,9 @@ public class MyOrchestrator : TaskOrchestrator<{inputType}, {outputType}> .ContinueWith(t => ({outputType})(t.Result ?? default({outputType})!), TaskContinuationOptions.ExecuteSynchronously); }} +/// +/// Schedules a new instance of the orchestrator. +/// /// public static Task ScheduleNewMyOrchestratorInstanceAsync( this IOrchestrationSubmitter client, {expectedInputParameter}, StartOrchestrationOptions? options = null) @@ -221,6 +240,9 @@ public static Task ScheduleNewMyOrchestratorInstanceAsync( return client.ScheduleNewOrchestrationInstanceAsync(""MyOrchestrator"", input, options); }} +/// +/// Calls the sub-orchestrator. +/// /// public static Task<{outputType}> CallMyOrchestratorAsync( this TaskOrchestrationContext context, {expectedInputParameter}, TaskOptions? options = null) @@ -291,6 +313,9 @@ public abstract class MyOrchestratorBase : TaskOrchestrator<{inputType}, {output .ContinueWith(t => ({outputType})(t.Result ?? default({outputType})!), TaskContinuationOptions.ExecuteSynchronously); }} +/// +/// Schedules a new instance of the orchestrator. +/// /// public static Task ScheduleNewMyOrchestratorInstanceAsync( this IOrchestrationSubmitter client, {expectedInputParameter}, StartOrchestrationOptions? options = null) @@ -298,6 +323,9 @@ public static Task ScheduleNewMyOrchestratorInstanceAsync( return client.ScheduleNewOrchestrationInstanceAsync(""MyOrchestrator"", input, options); }} +/// +/// Calls the sub-orchestrator. +/// /// public static Task<{outputType}> CallMyOrchestratorAsync( this TaskOrchestrationContext context, {expectedInputParameter}, TaskOptions? options = null) @@ -493,6 +521,9 @@ public static Task MyOrchestrator([OrchestrationTrigger] TaskOrchestrati .ContinueWith(t => (string)(t.Result ?? default(string)!), TaskContinuationOptions.ExecuteSynchronously); }} +/// +/// Schedules a new instance of the orchestrator. +/// /// public static Task ScheduleNewMyOrchestratorInstanceAsync( this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) @@ -500,6 +531,9 @@ public static Task ScheduleNewMyOrchestratorInstanceAsync( return client.ScheduleNewOrchestrationInstanceAsync(""MyOrchestrator"", input, options); }} +/// +/// Calls the sub-orchestrator. +/// /// public static Task CallMyOrchestratorAsync( this TaskOrchestrationContext context, int input, TaskOptions? options = null) @@ -507,6 +541,10 @@ public static Task CallMyOrchestratorAsync( return context.CallSubOrchestratorAsync(""MyOrchestrator"", input, options); }} +/// +/// Calls the activity. +/// +/// public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) {{ return ctx.CallActivityAsync(""MyActivity"", input, options); diff --git a/test/Generators.Tests/ClassBasedSyntaxTests.cs b/test/Generators.Tests/ClassBasedSyntaxTests.cs index d0cf51d5..c3abc6c5 100644 --- a/test/Generators.Tests/ClassBasedSyntaxTests.cs +++ b/test/Generators.Tests/ClassBasedSyntaxTests.cs @@ -31,6 +31,9 @@ class MyOrchestrator : TaskOrchestrator<{type}, string> string expectedOutput = TestHelpers.WrapAndFormat( GeneratedClassName, methodList: $@" +/// +/// Schedules a new instance of the orchestrator. +/// /// public static Task ScheduleNewMyOrchestratorInstanceAsync( this IOrchestrationSubmitter client, {input}, StartOrchestrationOptions? options = null) @@ -38,6 +41,9 @@ public static Task ScheduleNewMyOrchestratorInstanceAsync( return client.ScheduleNewOrchestrationInstanceAsync(""MyOrchestrator"", input, options); }} +/// +/// Calls the sub-orchestrator. +/// /// public static Task CallMyOrchestratorAsync( this TaskOrchestrationContext context, {input}, TaskOptions? options = null) @@ -80,6 +86,9 @@ abstract class MyOrchestratorBase : TaskOrchestrator string expectedOutput = TestHelpers.WrapAndFormat( GeneratedClassName, methodList: @" +/// +/// Schedules a new instance of the orchestrator. +/// /// public static Task ScheduleNewMyOrchestratorInstanceAsync( this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) @@ -87,6 +96,9 @@ public static Task ScheduleNewMyOrchestratorInstanceAsync( return client.ScheduleNewOrchestrationInstanceAsync(""MyOrchestrator"", input, options); } +/// +/// Calls the sub-orchestrator. +/// /// public static Task CallMyOrchestratorAsync( this TaskOrchestrationContext context, int input, TaskOptions? options = null) @@ -129,6 +141,10 @@ class MyActivity : TaskActivity<{type}, string> string expectedOutput = TestHelpers.WrapAndFormat( GeneratedClassName, methodList: $@" +/// +/// Calls the activity. +/// +/// public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, {input}, TaskOptions? options = null) {{ return ctx.CallActivityAsync(""MyActivity"", input, options); @@ -169,6 +185,10 @@ public class MyClass { } string expectedOutput = TestHelpers.WrapAndFormat( generatedClassName: "GeneratedDurableTaskExtensions", methodList: @" +/// +/// Calls the activity. +/// +/// public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, MyNS.MyClass input, TaskOptions? options = null) { return ctx.CallActivityAsync(""MyActivity"", input, options); @@ -211,6 +231,10 @@ public class MyClass { } string expectedOutput = TestHelpers.WrapAndFormat( GeneratedClassName, methodList: @" +/// +/// Calls the activity. +/// +/// public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, MyNS.MyClass input, TaskOptions? options = null) { return ctx.CallActivityAsync(""MyActivity"", input, options); @@ -250,6 +274,10 @@ abstract class MyActivityBase : TaskActivity string expectedOutput = TestHelpers.WrapAndFormat( GeneratedClassName, methodList: @" +/// +/// Calls the activity. +/// +/// public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) { return ctx.CallActivityAsync(""MyActivity"", input, options); @@ -440,6 +468,9 @@ class MyEntity : TaskEntity string expectedOutput = TestHelpers.WrapAndFormat( GeneratedClassName, methodList: @" +/// +/// Schedules a new instance of the orchestrator. +/// /// public static Task ScheduleNewMyOrchestratorInstanceAsync( this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null) @@ -447,6 +478,9 @@ public static Task ScheduleNewMyOrchestratorInstanceAsync( return client.ScheduleNewOrchestrationInstanceAsync(""MyOrchestrator"", input, options); } +/// +/// Calls the sub-orchestrator. +/// /// public static Task CallMyOrchestratorAsync( this TaskOrchestrationContext context, int input, TaskOptions? options = null) @@ -454,6 +488,10 @@ public static Task CallMyOrchestratorAsync( return context.CallSubOrchestratorAsync(""MyOrchestrator"", input, options); } +/// +/// Calls the activity. +/// +/// public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null) { return ctx.CallActivityAsync(""MyActivity"", input, options); diff --git a/test/Grpc.IntegrationTests/OrchestrationPatterns.cs b/test/Grpc.IntegrationTests/OrchestrationPatterns.cs index ea3f387d..8203c57f 100644 --- a/test/Grpc.IntegrationTests/OrchestrationPatterns.cs +++ b/test/Grpc.IntegrationTests/OrchestrationPatterns.cs @@ -1195,8 +1195,105 @@ async Task OrchestratorFunc(TaskOrchestrationContext ctx, int counter) Assert.Equal(EventCount, metadata.ReadOutputAs()); } + [Fact] + public async Task CatchingActivityExceptionsByType() + { + TaskName orchestratorName = nameof(CatchingActivityExceptionsByType); + TaskName throwInvalidOpActivityName = "ThrowInvalidOp"; + TaskName throwArgumentActivityName = "ThrowArgument"; + TaskName successActivityName = "Success"; + + await using HostTestLifetime server = await this.StartWorkerAsync(b => + { + b.AddTasks(tasks => tasks + .AddOrchestratorFunc(orchestratorName, async ctx => + { + List results = new(); + + // Test 1: Catch InvalidOperationException + try + { + await ctx.CallActivityAsync(throwInvalidOpActivityName); + results.Add("No exception thrown"); + } + catch (TaskFailedException ex) when (ex.FailureDetails?.IsCausedBy() == true) + { + results.Add("Caught InvalidOperationException"); + } + catch (TaskFailedException) + { + results.Add("Caught wrong exception type"); + } + + // Test 2: Catch ArgumentException + try + { + await ctx.CallActivityAsync(throwArgumentActivityName); + results.Add("No exception thrown"); + } + catch (TaskFailedException ex) when (ex.FailureDetails?.IsCausedBy() == true) + { + results.Add("Caught ArgumentException"); + } + catch (TaskFailedException) + { + results.Add("Caught wrong exception type"); + } + + // Test 3: Successful activity should not throw + try + { + string result = await ctx.CallActivityAsync(successActivityName); + results.Add(result); + } + catch (TaskFailedException) + { + results.Add("Unexpected exception"); + } + + // Test 4: Catch with base Exception type + try + { + await ctx.CallActivityAsync(throwInvalidOpActivityName); + results.Add("No exception thrown"); + } + catch (TaskFailedException ex) when (ex.FailureDetails?.IsCausedBy() == true) + { + results.Add("Caught base Exception"); + } + + return results; + }) + .AddActivityFunc(throwInvalidOpActivityName, (TaskActivityContext ctx) => + { + throw new InvalidOperationException("Invalid operation"); + }) + .AddActivityFunc(throwArgumentActivityName, (TaskActivityContext ctx) => + { + throw new ArgumentException("Invalid argument"); + }) + .AddActivityFunc(successActivityName, (TaskActivityContext ctx) => + { + return "Success"; + })); + }); + + string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync(orchestratorName); + OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync( + instanceId, getInputsAndOutputs: true, this.TimeoutToken); + Assert.NotNull(metadata); + Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus); + + List? results = metadata.ReadOutputAs>(); + Assert.NotNull(results); + Assert.Equal(4, results!.Count); + Assert.Equal("Caught InvalidOperationException", results[0]); + Assert.Equal("Caught ArgumentException", results[1]); + Assert.Equal("Success", results[2]); + Assert.Equal("Caught base Exception", results[3]); + } + // TODO: Test for multiple external events with the same name - // TODO: Test for catching activity exceptions of specific types [Fact] public async Task ActivityInput_ComplexTypesInDictionary_PreservesTypes()