diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index 837a0ba0..5a5d79b1 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -101,6 +101,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InProcessTestHost", "src\In EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InProcessTestHost.Tests", "test\InProcessTestHost.Tests\InProcessTestHost.Tests.csproj", "{B894780C-338F-475E-8E84-56AFA8197A06}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventsSample", "samples\EventsSample\EventsSample.csproj", "{34A3EC44-2609-A058-ED30-2F81C3F3A885}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -251,14 +253,6 @@ Global {D2779F32-A548-44F8-B60A-6AC018966C79}.Debug|Any CPU.Build.0 = Debug|Any CPU {D2779F32-A548-44F8-B60A-6AC018966C79}.Release|Any CPU.ActiveCfg = Release|Any CPU {D2779F32-A548-44F8-B60A-6AC018966C79}.Release|Any CPU.Build.0 = Release|Any CPU - {5F1E1662-D2D1-4325-BFE3-6AE23A8A4D7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5F1E1662-D2D1-4325-BFE3-6AE23A8A4D7E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5F1E1662-D2D1-4325-BFE3-6AE23A8A4D7E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5F1E1662-D2D1-4325-BFE3-6AE23A8A4D7E}.Release|Any CPU.Build.0 = Release|Any CPU - {B894780C-338F-475E-8E84-56AFA8197A06}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B894780C-338F-475E-8E84-56AFA8197A06}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B894780C-338F-475E-8E84-56AFA8197A06}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B894780C-338F-475E-8E84-56AFA8197A06}.Release|Any CPU.Build.0 = Release|Any CPU {6EB9D002-62C8-D6C1-62A8-14C54CA6DBBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {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 @@ -271,6 +265,18 @@ Global {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 {FE1DA748-D6DB-E168-BC42-6DBBCEAF229C}.Release|Any CPU.Build.0 = Release|Any CPU + {5F1E1662-D2D1-4325-BFE3-6AE23A8A4D7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F1E1662-D2D1-4325-BFE3-6AE23A8A4D7E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F1E1662-D2D1-4325-BFE3-6AE23A8A4D7E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F1E1662-D2D1-4325-BFE3-6AE23A8A4D7E}.Release|Any CPU.Build.0 = Release|Any CPU + {B894780C-338F-475E-8E84-56AFA8197A06}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B894780C-338F-475E-8E84-56AFA8197A06}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B894780C-338F-475E-8E84-56AFA8197A06}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B894780C-338F-475E-8E84-56AFA8197A06}.Release|Any CPU.Build.0 = Release|Any CPU + {34A3EC44-2609-A058-ED30-2F81C3F3A885}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {34A3EC44-2609-A058-ED30-2F81C3F3A885}.Debug|Any CPU.Build.0 = Debug|Any CPU + {34A3EC44-2609-A058-ED30-2F81C3F3A885}.Release|Any CPU.ActiveCfg = Release|Any CPU + {34A3EC44-2609-A058-ED30-2F81C3F3A885}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -316,11 +322,12 @@ Global {A89B766C-987F-4C9F-8937-D0AB9FE640C8} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {100348B5-4D97-4A3F-B777-AB14F276F8FE} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} {D2779F32-A548-44F8-B60A-6AC018966C79} = {E5637F81-2FB9-4CD7-900D-455363B142A7} - {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} + {5F1E1662-D2D1-4325-BFE3-6AE23A8A4D7E} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B} + {B894780C-338F-475E-8E84-56AFA8197A06} = {E5637F81-2FB9-4CD7-900D-455363B142A7} + {34A3EC44-2609-A058-ED30-2F81C3F3A885} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71} diff --git a/samples/AzureFunctionsApp/ApprovalOrchestrator.cs b/samples/AzureFunctionsApp/ApprovalOrchestrator.cs new file mode 100644 index 00000000..9ecf059a --- /dev/null +++ b/samples/AzureFunctionsApp/ApprovalOrchestrator.cs @@ -0,0 +1,121 @@ +// 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 AzureFunctionsApp.Approval; + +/// +/// HTTP-triggered function that starts the orchestration. +/// +public static class ApprovalOrchestratorStarter +{ + [Function(nameof(StartApprovalOrchestrator))] + public static async Task StartApprovalOrchestrator( + [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req, + [DurableClient] DurableTaskClient client, + FunctionContext executionContext) + { + ILogger logger = executionContext.GetLogger(nameof(StartApprovalOrchestrator)); + + string? requestName = await req.ReadAsStringAsync(); + if (string.IsNullOrEmpty(requestName)) + { + requestName = "Sample Request"; + } + + // Use the generated type-safe extension method to start the orchestration + string instanceId = await client.ScheduleNewApprovalOrchestratorInstanceAsync(requestName); + logger.LogInformation("Started approval orchestration with instance ID = {instanceId}", instanceId); + + return client.CreateCheckStatusResponse(req, instanceId); + } + + [Function(nameof(SendApprovalEvent))] + public static async Task SendApprovalEvent( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "approval/{instanceId}")] HttpRequestData req, + [DurableClient] DurableTaskClient client, + string instanceId, + FunctionContext executionContext) + { + ILogger logger = executionContext.GetLogger(nameof(SendApprovalEvent)); + + string? approverName = await req.ReadAsStringAsync(); + bool isApproved = req.Url.Query.Contains("approve=true"); + + // Raise the ApprovalEvent + await client.RaiseEventAsync(instanceId, "ApprovalEvent", new ApprovalEvent(isApproved, approverName)); + logger.LogInformation("Sent approval event to instance {instanceId}: approved={isApproved}, approver={approverName}", + instanceId, isApproved, approverName); + + var response = req.CreateResponse(System.Net.HttpStatusCode.Accepted); + await response.WriteStringAsync($"Approval event sent to instance {instanceId}"); + return response; + } +} + +/// +/// Example event type for approval workflows. +/// The DurableEventAttribute generates a strongly-typed WaitForApprovalEventAsync method. +/// +[DurableEvent(nameof(ApprovalEvent))] +public sealed record ApprovalEvent(bool Approved, string? Approver); + +/// +/// Orchestrator that demonstrates strongly-typed external events. +/// +[DurableTask(nameof(ApprovalOrchestrator))] +public class ApprovalOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string requestName) + { + ILogger logger = context.CreateReplaySafeLogger(); + logger.LogInformation("Approval request received for: {requestName}", requestName); + + // Send a notification that approval is required + await context.CallNotifyApprovalRequiredAsync(requestName); + + // Wait for approval event using the generated strongly-typed method + // This method is generated by the source generator from the DurableEventAttribute + ApprovalEvent approvalEvent = await context.WaitForApprovalEventAsync(); + + string result; + if (approvalEvent.Approved) + { + result = $"Request '{requestName}' was approved by {approvalEvent.Approver ?? "unknown"}"; + logger.LogInformation("Request approved: {result}", result); + } + else + { + result = $"Request '{requestName}' was rejected by {approvalEvent.Approver ?? "unknown"}"; + logger.LogInformation("Request rejected: {result}", result); + } + + return result; + } +} + +/// +/// Activity that simulates sending an approval notification. +/// +[DurableTask(nameof(NotifyApprovalRequired))] +public class NotifyApprovalRequired : TaskActivity +{ + readonly ILogger logger; + + public NotifyApprovalRequired(ILogger logger) + { + this.logger = logger; + } + + public override Task RunAsync(TaskActivityContext context, string requestName) + { + this.logger.LogInformation("Approval required for: {requestName} (Instance: {instanceId})", + requestName, context.InstanceId); + return Task.FromResult("Notification sent"); + } +} diff --git a/samples/AzureFunctionsApp/AzureFunctionsApp.csproj b/samples/AzureFunctionsApp/AzureFunctionsApp.csproj index ad64c303..25d824fa 100644 --- a/samples/AzureFunctionsApp/AzureFunctionsApp.csproj +++ b/samples/AzureFunctionsApp/AzureFunctionsApp.csproj @@ -17,6 +17,8 @@ + + diff --git a/samples/EventsSample/Events.cs b/samples/EventsSample/Events.cs new file mode 100644 index 00000000..e284acae --- /dev/null +++ b/samples/EventsSample/Events.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask; + +namespace EventsSample; + +/// +/// Example event type annotated with DurableEventAttribute. +/// This generates a strongly-typed WaitForApprovalEventAsync method. +/// +[DurableEvent(nameof(ApprovalEvent))] +public sealed record ApprovalEvent(bool Approved, string? Approver); + +/// +/// Another example event type with custom name. +/// This generates a WaitForDataReceivedAsync method that waits for "DataReceived" event. +/// +[DurableEvent("DataReceived")] +public sealed record DataReceivedEvent(int Id, string Data); diff --git a/samples/EventsSample/EventsSample.csproj b/samples/EventsSample/EventsSample.csproj new file mode 100644 index 00000000..7df90c5a --- /dev/null +++ b/samples/EventsSample/EventsSample.csproj @@ -0,0 +1,24 @@ + + + + Exe + net6.0;net8.0;net10.0 + enable + + + + + + + + + + + + + + + + diff --git a/samples/EventsSample/Program.cs b/samples/EventsSample/Program.cs new file mode 100644 index 00000000..90487bc5 --- /dev/null +++ b/samples/EventsSample/Program.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// This sample demonstrates the use of strongly-typed external events with DurableEventAttribute. + +using EventsSample; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.AzureManaged; +using Microsoft.DurableTask.Worker; +using Microsoft.DurableTask.Worker.AzureManaged; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); + +string schedulerConnectionString = builder.Configuration.GetValue("DURABLE_TASK_SCHEDULER_CONNECTION_STRING") + ?? throw new InvalidOperationException("DURABLE_TASK_SCHEDULER_CONNECTION_STRING is not set."); + +builder.Services.AddDurableTaskClient(clientBuilder => clientBuilder.UseDurableTaskScheduler(schedulerConnectionString)); + +builder.Services.AddDurableTaskWorker(workerBuilder => +{ + workerBuilder.AddTasks(tasks => + { + tasks.AddOrchestrator(); + tasks.AddActivity(); + tasks.AddOrchestrator(); + tasks.AddActivity(); + }); + + workerBuilder.UseDurableTaskScheduler(schedulerConnectionString); +}); + +IHost host = builder.Build(); +await host.StartAsync(); + +await using DurableTaskClient client = host.Services.GetRequiredService(); + +Console.WriteLine("=== Strongly-Typed Events Sample ==="); +Console.WriteLine(); + +// Example 1: Approval workflow +Console.WriteLine("Starting approval workflow..."); +string approvalInstanceId = await client.ScheduleNewOrchestrationInstanceAsync("ApprovalOrchestrator", "Important Request"); +Console.WriteLine($"Started orchestration with ID: {approvalInstanceId}"); +Console.WriteLine(); + +// Wait a moment for the notification to be sent +await Task.Delay(1000); + +// Simulate approval +Console.WriteLine("Simulating approval event..."); +await client.RaiseEventAsync(approvalInstanceId, "ApprovalEvent", new ApprovalEvent(true, "John Doe")); + +// Wait for completion +OrchestrationMetadata approvalResult = await client.WaitForInstanceCompletionAsync( + approvalInstanceId, + getInputsAndOutputs: true); +Console.WriteLine($"Approval workflow result: {approvalResult.ReadOutputAs()}"); +Console.WriteLine(); + +// Example 2: Data processing workflow +Console.WriteLine("Starting data processing workflow..."); +string dataInstanceId = await client.ScheduleNewOrchestrationInstanceAsync("DataProcessingOrchestrator", "test-input"); +Console.WriteLine($"Started orchestration with ID: {dataInstanceId}"); +Console.WriteLine(); + +// Wait a moment +await Task.Delay(1000); + +// Send data event +Console.WriteLine("Sending data event..."); +await client.RaiseEventAsync(dataInstanceId, "DataReceived", new DataReceivedEvent(123, "Sample Data")); + +// Wait for completion +OrchestrationMetadata dataResult = await client.WaitForInstanceCompletionAsync( + dataInstanceId, + getInputsAndOutputs: true); +Console.WriteLine($"Data processing result: {dataResult.ReadOutputAs()}"); +Console.WriteLine(); + +Console.WriteLine("Sample completed successfully!"); +await host.StopAsync(); diff --git a/samples/EventsSample/README.md b/samples/EventsSample/README.md new file mode 100644 index 00000000..a90a3c13 --- /dev/null +++ b/samples/EventsSample/README.md @@ -0,0 +1,88 @@ +# Strongly-Typed Events Sample + +This sample demonstrates the use of strongly-typed external events using the `DurableEventAttribute`. + +## Overview + +The `DurableEventAttribute` allows you to define event types that automatically generate strongly-typed extension methods for waiting on external events in orchestrations. This provides compile-time type safety and better IntelliSense support. + +## Key Features + +1. **Strongly-Typed Event Definitions**: Define event types using records or classes with the `[DurableEvent]` attribute +2. **Generated Extension Methods**: The source generator automatically creates `WaitFor{EventName}Async` methods +3. **Type Safety**: Event payloads are strongly-typed, reducing runtime errors + +## Sample Code + +### Defining an Event + +```csharp +[DurableEvent(nameof(ApprovalEvent))] +public sealed record ApprovalEvent(bool Approved, string? Approver); +``` + +This generates an extension method: + +```csharp +public static Task WaitForApprovalEventAsync( + this TaskOrchestrationContext context, + CancellationToken cancellationToken = default); +``` + +### Using the Generated Method in an Orchestrator + +```csharp +[DurableTask("ApprovalOrchestrator")] +public class ApprovalOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string requestName) + { + // Wait for approval event using the generated strongly-typed method + ApprovalEvent approvalEvent = await context.WaitForApprovalEventAsync(); + + if (approvalEvent.Approved) + { + return $"Request approved by {approvalEvent.Approver}"; + } + else + { + return $"Request rejected by {approvalEvent.Approver}"; + } + } +} +``` + +### Raising Events from Client Code + +```csharp +await client.RaiseEventAsync( + instanceId, + "ApprovalEvent", + new ApprovalEvent(true, "John Doe")); +``` + +## Running the Sample + +This sample is configured to use **Durable Task Scheduler (DTS)** (no local gRPC sidecar required). + +1. Set the DTS connection string: + ```bash + export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="..." + ``` +2. Run the sample: + ```bash + dotnet run + ``` + +The sample will: +1. Start an approval workflow and wait for an approval event +2. Raise an approval event from the client +3. Complete the workflow with the approval result +4. Start a data processing workflow and demonstrate another event type + +## Benefits + +- **Type Safety**: Compile-time checking of event payloads +- **IntelliSense**: Better IDE support for discovering available event methods +- **Less Boilerplate**: No need to manually call `WaitForExternalEvent` with string literals +- **Refactoring Support**: Renaming event types automatically updates generated code diff --git a/samples/EventsSample/Tasks.cs b/samples/EventsSample/Tasks.cs new file mode 100644 index 00000000..4f8756fb --- /dev/null +++ b/samples/EventsSample/Tasks.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DurableTask; + +namespace EventsSample; + +/// +/// Orchestrator that demonstrates strongly-typed external events. +/// +[DurableTask("ApprovalOrchestrator")] +public class ApprovalOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string requestName) + { + // Send a notification requesting approval + await context.CallNotifyApprovalRequiredAsync(requestName); + + // Wait for approval event using the generated strongly-typed method + // Note: WaitForApprovalEventAsync is generated by the source generator + ApprovalEvent approvalEvent = await context.WaitForApprovalEventAsync(); + + return $"Request '{requestName}' was {(approvalEvent.Approved ? "approved" : "rejected")} by {approvalEvent.Approver ?? "unknown"}"; + } +} + +/// +/// Activity that simulates sending an approval notification. +/// +[DurableTask("NotifyApprovalRequired")] +public class NotifyApprovalRequiredActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, string requestName) + { + Console.WriteLine($"[{DateTime.UtcNow:HH:mm:ss}] Approval required for: {requestName}"); + Console.WriteLine($" Instance ID: {context.InstanceId}"); + return Task.FromResult("Notification sent"); + } +} + +/// +/// Orchestrator that demonstrates waiting for multiple event types. +/// +[DurableTask("DataProcessingOrchestrator")] +public class DataProcessingOrchestrator : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string input) + { + // Wait for data using the generated strongly-typed method + DataReceivedEvent dataEvent = await context.WaitForDataReceivedAsync(); + + // Process the data + string result = await context.CallProcessDataAsync(dataEvent.Data); + + return $"Processed data {dataEvent.Id}: {result}"; + } +} + +/// +/// Activity that processes data. +/// +[DurableTask("ProcessData")] +public class ProcessDataActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, string data) + { + Console.WriteLine($"[{DateTime.UtcNow:HH:mm:ss}] Processing data: {data}"); + return Task.FromResult($"Processed: {data.ToUpper()}"); + } +} diff --git a/src/Abstractions/DurableEventAttribute.cs b/src/Abstractions/DurableEventAttribute.cs new file mode 100644 index 00000000..f8cb18b2 --- /dev/null +++ b/src/Abstractions/DurableEventAttribute.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask; + +/// +/// Indicates that the attributed type represents a durable event. +/// +/// +/// This attribute is meant to be used on type definitions to generate strongly-typed +/// external event methods for orchestration contexts. +/// It is used specifically by build-time source generators to generate type-safe methods for waiting +/// for external events in orchestrations. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class DurableEventAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// The name of the durable event. If not specified, the type name is used as the implied name of the durable event. + /// + public DurableEventAttribute(string? name = null) + { + // This logic cannot become too complex as code-generator relies on examining the constructor arguments. + this.Name = string.IsNullOrEmpty(name) ? default : new TaskName(name!); + } + + /// + /// Gets the name of the durable event. + /// + public TaskName Name { get; } +} diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs index 19a24cc9..0b4e717e 100644 --- a/src/Generators/DurableTaskSourceGenerator.cs +++ b/src/Generators/DurableTaskSourceGenerator.cs @@ -49,6 +49,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context) transform: static (ctx, _) => GetDurableTaskTypeInfo(ctx)) .Where(static info => info != null)!; + // Create providers for DurableEvent attributes + IncrementalValuesProvider durableEventAttributes = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => node is AttributeSyntax, + transform: static (ctx, _) => GetDurableEventTypeInfo(ctx)) + .Where(static info => info != null)!; + // Create providers for Durable Functions IncrementalValuesProvider durableFunctions = context.SyntaxProvider .CreateSyntaxProvider( @@ -57,14 +64,21 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Where(static func => func != null)!; // Collect all results and check if Durable Functions is referenced - IncrementalValueProvider<(Compilation, ImmutableArray, ImmutableArray)> compilationAndTasks = + IncrementalValueProvider<(Compilation, ImmutableArray, ImmutableArray, ImmutableArray)> compilationAndTasks = durableTaskAttributes.Collect() + .Combine(durableEventAttributes.Collect()) .Combine(durableFunctions.Collect()) .Combine(context.CompilationProvider) - .Select((x, _) => (x.Right, x.Left.Left, x.Left.Right)); + // Roslyn's IncrementalValueProvider.Combine creates nested tuple pairs: ((Left, Right), Right) + // After multiple .Combine() calls, we unpack the nested structure: + // x.Right = Compilation + // x.Left.Left.Left = DurableTaskAttributes (orchestrators, activities, entities) + // x.Left.Left.Right = DurableEventAttributes (events) + // x.Left.Right = DurableFunctions (Azure Functions metadata) + .Select((x, _) => (x.Right, x.Left.Left.Left, x.Left.Left.Right, x.Left.Right)); // Generate the source - context.RegisterSourceOutput(compilationAndTasks, static (spc, source) => Execute(spc, source.Item1, source.Item2, source.Item3)); + context.RegisterSourceOutput(compilationAndTasks, static (spc, source) => Execute(spc, source.Item1, source.Item2, source.Item3, source.Item4)); } static DurableTaskTypeInfo? GetDurableTaskTypeInfo(GeneratorSyntaxContext context) @@ -161,6 +175,49 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return new DurableTaskTypeInfo(className, taskName, inputType, outputType, kind); } + static DurableEventTypeInfo? GetDurableEventTypeInfo(GeneratorSyntaxContext context) + { + AttributeSyntax attribute = (AttributeSyntax)context.Node; + + ITypeSymbol? attributeType = context.SemanticModel.GetTypeInfo(attribute.Name).Type; + if (attributeType?.ToString() != "Microsoft.DurableTask.DurableEventAttribute") + { + return null; + } + + // DurableEventAttribute can be applied to both class and struct (record) + TypeDeclarationSyntax? typeDeclaration = attribute.Parent?.Parent as TypeDeclarationSyntax; + if (typeDeclaration == null) + { + return null; + } + + // Verify that the attribute is being used on a non-abstract type + if (typeDeclaration.Modifiers.Any(SyntaxKind.AbstractKeyword)) + { + return null; + } + + if (context.SemanticModel.GetDeclaredSymbol(typeDeclaration) is not ITypeSymbol eventType) + { + return null; + } + + string eventName = eventType.Name; + + if (attribute.ArgumentList?.Arguments.Count > 0) + { + ExpressionSyntax expression = attribute.ArgumentList.Arguments[0].Expression; + Optional constantValue = context.SemanticModel.GetConstantValue(expression); + if (constantValue.HasValue && constantValue.Value is string value) + { + eventName = value; + } + } + + return new DurableEventTypeInfo(eventName, eventType); + } + static DurableFunction? GetDurableFunction(GeneratorSyntaxContext context) { MethodDeclarationSyntax method = (MethodDeclarationSyntax)context.Node; @@ -177,9 +234,10 @@ static void Execute( SourceProductionContext context, Compilation compilation, ImmutableArray allTasks, + ImmutableArray allEvents, ImmutableArray allFunctions) { - if (allTasks.IsDefaultOrEmpty && allFunctions.IsDefaultOrEmpty) + if (allTasks.IsDefaultOrEmpty && allEvents.IsDefaultOrEmpty && allFunctions.IsDefaultOrEmpty) { return; } @@ -210,7 +268,7 @@ static void Execute( } } - int found = activities.Count + orchestrators.Count + entities.Count + allFunctions.Length; + int found = activities.Count + orchestrators.Count + entities.Count + allEvents.Length + allFunctions.Length; if (found == 0) { return; @@ -221,6 +279,7 @@ static void Execute( #nullable enable using System; +using System.Threading; using System.Threading.Tasks; using Microsoft.DurableTask.Internal;"); @@ -287,6 +346,13 @@ public static class GeneratedDurableTaskExtensions AddActivityCallMethod(sourceBuilder, function); } + // Generate WaitFor{EventName}Async methods for each event type + foreach (DurableEventTypeInfo eventInfo in allEvents) + { + AddEventWaitMethod(sourceBuilder, eventInfo); + AddEventSendMethod(sourceBuilder, eventInfo); + } + if (isDurableFunctions) { if (activities.Count > 0) @@ -299,11 +365,15 @@ public static class GeneratedDurableTaskExtensions else { // ASP.NET Core-specific service registration methods - AddRegistrationMethodForAllTasks( - sourceBuilder, - orchestrators, - activities, - entities); + // Only generate if there are actually tasks to register + if (orchestrators.Count > 0 || activities.Count > 0 || entities.Count > 0) + { + AddRegistrationMethodForAllTasks( + sourceBuilder, + orchestrators, + activities, + entities); + } } sourceBuilder.AppendLine(" }").AppendLine("}"); @@ -376,6 +446,32 @@ static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableFunction a }}"); } + static void AddEventWaitMethod(StringBuilder sourceBuilder, DurableEventTypeInfo eventInfo) + { + sourceBuilder.AppendLine($@" + /// + /// Waits for an external event of type . + /// + /// + public static Task<{eventInfo.TypeName}> WaitFor{eventInfo.EventName}Async(this TaskOrchestrationContext context, CancellationToken cancellationToken = default) + {{ + return context.WaitForExternalEvent<{eventInfo.TypeName}>(""{eventInfo.EventName}"", cancellationToken); + }}"); + } + + static void AddEventSendMethod(StringBuilder sourceBuilder, DurableEventTypeInfo eventInfo) + { + sourceBuilder.AppendLine($@" + /// + /// Sends an external event of type to another orchestration instance. + /// + /// + public static void Send{eventInfo.EventName}(this TaskOrchestrationContext context, string instanceId, {eventInfo.TypeName} eventData) + {{ + context.SendEvent(instanceId, ""{eventInfo.EventName}"", eventData); + }}"); + } + static void AddActivityFunctionDeclaration(StringBuilder sourceBuilder, DurableTaskTypeInfo activity) { // GeneratedActivityContext is a generated class that we use for each generated activity trigger definition. @@ -533,5 +629,34 @@ static string GetRenderedTypeExpression(ITypeSymbol? symbol) return expression; } } + + class DurableEventTypeInfo + { + public DurableEventTypeInfo(string eventName, ITypeSymbol eventType) + { + this.TypeName = GetRenderedTypeExpression(eventType); + this.EventName = eventName; + } + + public string TypeName { get; } + public string EventName { get; } + + static string GetRenderedTypeExpression(ITypeSymbol? symbol) + { + if (symbol == null) + { + return "object"; + } + + string expression = symbol.ToString(); + if (expression.StartsWith("System.", StringComparison.Ordinal) + && symbol.ContainingNamespace.Name == "System") + { + expression = expression.Substring("System.".Length); + } + + return expression; + } + } } } diff --git a/test/Generators.Tests/ClassBasedSyntaxTests.cs b/test/Generators.Tests/ClassBasedSyntaxTests.cs index c3abc6c5..638d5a26 100644 --- a/test/Generators.Tests/ClassBasedSyntaxTests.cs +++ b/test/Generators.Tests/ClassBasedSyntaxTests.cs @@ -511,4 +511,126 @@ internal static DurableTaskRegistry AddAllGeneratedTasks(this DurableTaskRegistr expectedOutput, isDurableFunctions: false); } + + [Fact] + public Task Events_BasicRecord() + { + string code = @" +#nullable enable +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableEvent(nameof(ApprovalEvent))] +public sealed record ApprovalEvent(bool Approved, string? Approver);"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Waits for an external event of type . +/// +/// +public static Task WaitForApprovalEventAsync(this TaskOrchestrationContext context, CancellationToken cancellationToken = default) +{ + return context.WaitForExternalEvent(""ApprovalEvent"", cancellationToken); +} + +/// +/// Sends an external event of type to another orchestration instance. +/// +/// +public static void SendApprovalEvent(this TaskOrchestrationContext context, string instanceId, ApprovalEvent eventData) +{ + context.SendEvent(instanceId, ""ApprovalEvent"", eventData); +}"); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false); + } + + [Fact] + public Task Events_ClassWithExplicitName() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; + +[DurableEvent(""CustomEventName"")] +public class MyEventData +{ + public string Message { get; set; } +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Waits for an external event of type . +/// +/// +public static Task WaitForCustomEventNameAsync(this TaskOrchestrationContext context, CancellationToken cancellationToken = default) +{ + return context.WaitForExternalEvent(""CustomEventName"", cancellationToken); +} + +/// +/// Sends an external event of type to another orchestration instance. +/// +/// +public static void SendCustomEventName(this TaskOrchestrationContext context, string instanceId, MyEventData eventData) +{ + context.SendEvent(instanceId, ""CustomEventName"", eventData); +}"); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false); + } + + [Fact] + public Task Events_WithNamespace() + { + string code = @" +using System.Threading.Tasks; +using Microsoft.DurableTask; +using MyNS; + +namespace MyNS +{ + [DurableEvent(nameof(DataReceivedEvent))] + public record DataReceivedEvent(int Id, string Data); +}"; + + string expectedOutput = TestHelpers.WrapAndFormat( + GeneratedClassName, + methodList: @" +/// +/// Waits for an external event of type . +/// +/// +public static Task WaitForDataReceivedEventAsync(this TaskOrchestrationContext context, CancellationToken cancellationToken = default) +{ + return context.WaitForExternalEvent(""DataReceivedEvent"", cancellationToken); +} + +/// +/// Sends an external event of type to another orchestration instance. +/// +/// +public static void SendDataReceivedEvent(this TaskOrchestrationContext context, string instanceId, MyNS.DataReceivedEvent eventData) +{ + context.SendEvent(instanceId, ""DataReceivedEvent"", eventData); +}"); + + return TestHelpers.RunTestAsync( + GeneratedFileName, + code, + expectedOutput, + isDurableFunctions: false); + } } diff --git a/test/Generators.Tests/Utils/TestHelpers.cs b/test/Generators.Tests/Utils/TestHelpers.cs index 67f030d9..960a525f 100644 --- a/test/Generators.Tests/Utils/TestHelpers.cs +++ b/test/Generators.Tests/Utils/TestHelpers.cs @@ -61,6 +61,7 @@ public static string WrapAndFormat(string generatedClassName, string methodList, string formattedMethodList = IndentLines(spaces: 8, methodList); string usings = @" using System; +using System.Threading; using System.Threading.Tasks; using Microsoft.DurableTask.Internal;";