Skip to content

Commit 8ae62ae

Browse files
authored
Add entity source generation support for Durable Functions (#533)
1 parent 8781ec3 commit 8ae62ae

File tree

6 files changed

+285
-24
lines changed

6 files changed

+285
-24
lines changed

samples/AzureFunctionsApp/AzureFunctionsApp.csproj

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@
55
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
66
<OutputType>Exe</OutputType>
77
<Nullable>enable</Nullable>
8+
<!-- Disable SDK's source generation to allow reflection-based discovery of source-generated functions -->
9+
<FunctionsEnableExecutorSourceGen>false</FunctionsEnableExecutorSourceGen>
10+
<FunctionsEnableWorkerIndexing>false</FunctionsEnableWorkerIndexing>
811
</PropertyGroup>
912

1013
<ItemGroup>
1114
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
1215
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
1316
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" />
1417
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" OutputItemType="Analyzer" />
15-
<PackageReference Include="Microsoft.DurableTask.Generators" OutputItemType="Analyzer" />
18+
<!-- Reference the source generator project directly for local development -->
19+
<ProjectReference Include="..\..\src\Generators\Generators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
1620
</ItemGroup>
1721

1822
<ItemGroup>

samples/AzureFunctionsApp/Entities/Counter.cs

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ namespace AzureFunctionsApp.Entities;
2929
/// Example on how to dispatch to an entity which directly implements TaskEntity<TState>. Using TaskEntity<TState> gives
3030
/// the added benefit of being able to use DI. When using TaskEntity<TState>, state is deserialized to the "State"
3131
/// property. No other properties on this type will be serialized/deserialized.
32+
///
33+
/// Source generators are used to generate the [Function] method with [EntityTrigger] binding automatically.
34+
/// The generated function will be named "Counter" (based on the class name or the DurableTask attribute value).
3235
/// </summary>
36+
[DurableTask(nameof(Counter))]
3337
public class Counter : TaskEntity<int>
3438
{
3539
readonly ILogger logger;
@@ -49,19 +53,21 @@ public int Add(int input)
4953

5054
public void Reset() => this.State = 0;
5155

52-
[Function("Counter")]
53-
public Task DispatchAsync([EntityTrigger] TaskEntityDispatcher dispatcher)
54-
{
55-
// Can dispatch to a TaskEntity<TState> by passing a instance.
56-
return dispatcher.DispatchAsync(this);
57-
}
56+
// Note: The [Function("Counter")] method is now auto-generated by the source generator.
57+
// The generated code will look like:
58+
// [Function(nameof(Counter))]
59+
// public static Task Counter([EntityTrigger] TaskEntityDispatcher dispatcher)
60+
// {
61+
// return dispatcher.DispatchAsync<Counter>();
62+
// }
5863

5964
[Function("Counter_Alt")]
6065
public static Task DispatchStaticAsync([EntityTrigger] TaskEntityDispatcher dispatcher)
6166
{
62-
// Can also dispatch to a TaskEntity<TState> by using a static method.
63-
// However, this is a different entity ID - "counter_alt" and not "counter". Even though it uses the same
64-
// entity implementation, the function attribute has a different name, which determines the entity ID.
67+
// This is kept as a manual example showing how to create an alternative entity function
68+
// with a different name. This creates a separate entity ID "counter_alt" vs "counter".
69+
// Even though it uses the same entity implementation, the function attribute has a different name,
70+
// which determines the entity ID.
6571
return dispatcher.DispatchAsync<Counter>();
6672
}
6773
}

samples/AzureFunctionsApp/Entities/Lifetime.cs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Net;
55
using Microsoft.Azure.Functions.Worker;
66
using Microsoft.Azure.Functions.Worker.Http;
7+
using Microsoft.DurableTask;
78
using Microsoft.DurableTask.Client;
89
using Microsoft.DurableTask.Client.Entities;
910
using Microsoft.DurableTask.Entities;
@@ -16,7 +17,10 @@ namespace AzureFunctionsApp.Entities;
1617
/// is considered deleted when <see cref="TaskEntity{TState}.State"/> is <c>null</c> at the end of an operation. It
1718
/// is also possible to design an entity which remains stateless by always returning <c>null</c> from
1819
/// <see cref="InitializeState"/> and never assigning a non-null state.
20+
///
21+
/// Source generators are used to generate the [Function] method with [EntityTrigger] binding automatically.
1922
/// </summary>
23+
[DurableTask(nameof(Lifetime))]
2024
public class Lifetime : TaskEntity<MyState>
2125
{
2226
readonly ILogger logger;
@@ -34,14 +38,13 @@ public Lifetime(ILogger<Lifetime> logger)
3438
/// </summary>
3539
protected override bool AllowStateDispatch => base.AllowStateDispatch;
3640

37-
// NOTE: when using TaskEntity<TState>, you cannot use "RunAsync" as the entity trigger name as this conflicts
38-
// with the base class method 'RunAsync'.
39-
[Function(nameof(Lifetime))]
40-
public Task DispatchAsync([EntityTrigger] TaskEntityDispatcher dispatcher)
41-
{
42-
this.logger.LogInformation("Dispatching entity");
43-
return dispatcher.DispatchAsync(this);
44-
}
41+
// Note: The [Function(nameof(Lifetime))] method is now auto-generated by the source generator.
42+
// The generated code will look like:
43+
// [Function(nameof(Lifetime))]
44+
// public static Task Lifetime([EntityTrigger] TaskEntityDispatcher dispatcher)
45+
// {
46+
// return dispatcher.DispatchAsync<Lifetime>();
47+
// }
4548

4649
public MyState Get() => this.State;
4750

samples/AzureFunctionsApp/Entities/User.cs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ public record UserUpdate(string? Name, int? Age);
1818

1919
/// <summary>
2020
/// This sample demonstrates how to bind to <see cref="TaskEntityContext"/> as well as dispatch to orchestrations.
21+
///
22+
/// Source generators are used to generate the [Function] method with [EntityTrigger] binding automatically.
23+
/// The generated function will be named "User" (based on the class name or the DurableTask attribute value).
2124
/// </summary>
25+
[DurableTask(nameof(User))]
2226
public class UserEntity : TaskEntity<User>
2327
{
2428
readonly ILogger logger;
@@ -68,12 +72,13 @@ public void Greet(TaskEntityContext context, string? message = null)
6872
// this.Context.ScheduleNewOrchestration(nameof(Greeting.GreetingOrchestration), input);
6973
}
7074

71-
[Function(nameof(User))]
72-
public Task DispatchAsync([EntityTrigger] TaskEntityDispatcher dispatcher)
73-
{
74-
// Can dispatch to a TaskEntity<TState> by passing a instance.
75-
return dispatcher.DispatchAsync(this);
76-
}
75+
// Note: The [Function(nameof(User))] method is now auto-generated by the source generator.
76+
// The generated code will look like:
77+
// [Function(nameof(User))]
78+
// public static Task User([EntityTrigger] TaskEntityDispatcher dispatcher)
79+
// {
80+
// return dispatcher.DispatchAsync<UserEntity>();
81+
// }
7782

7883
protected override User InitializeState(TaskEntityOperation entityOperation)
7984
{

src/Generators/DurableTaskSourceGenerator.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,15 @@ public static class GeneratedDurableTaskExtensions
270270
}
271271
}
272272

273+
foreach (DurableTaskTypeInfo entity in entities)
274+
{
275+
if (isDurableFunctions)
276+
{
277+
// Generate the function definition required to trigger entities in Azure Functions
278+
AddEntityFunctionDeclaration(sourceBuilder, entity);
279+
}
280+
}
281+
273282
// Activity function triggers are supported for code-gen (but not orchestration triggers)
274283
IEnumerable<DurableFunction> activityTriggers = allFunctions.Where(
275284
df => df.Kind == DurableFunctionKind.Activity);
@@ -368,6 +377,17 @@ static void AddActivityFunctionDeclaration(StringBuilder sourceBuilder, DurableT
368377
}}");
369378
}
370379

380+
static void AddEntityFunctionDeclaration(StringBuilder sourceBuilder, DurableTaskTypeInfo entity)
381+
{
382+
// Generate the entity trigger function that dispatches to the entity implementation.
383+
sourceBuilder.AppendLine($@"
384+
[Function(nameof({entity.TaskName}))]
385+
public static Task {entity.TaskName}([EntityTrigger] TaskEntityDispatcher dispatcher)
386+
{{
387+
return dispatcher.DispatchAsync<{entity.TypeName}>();
388+
}}");
389+
}
390+
371391
/// <summary>
372392
/// Adds a custom ITaskActivityContext implementation used by code generated from <see cref="AddActivityFunctionDeclaration"/>.
373393
/// </summary>

test/Generators.Tests/AzureFunctionsTests.cs

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,4 +312,227 @@ await TestHelpers.RunTestAsync<DurableTaskSourceGenerator>(
312312
expectedOutput,
313313
isDurableFunctions: true);
314314
}
315+
316+
/// <summary>
317+
/// Verifies that using the class-based syntax for authoring entities generates
318+
/// <see cref="EntityTriggerAttribute"/> function triggers for Azure Functions.
319+
/// </summary>
320+
/// <param name="stateType">The entity state type.</param>
321+
[Theory]
322+
[InlineData("int")]
323+
[InlineData("string")]
324+
public async Task Entities_ClassBasedSyntax(string stateType)
325+
{
326+
string code = $@"
327+
#nullable enable
328+
using System.Threading.Tasks;
329+
using Microsoft.DurableTask;
330+
using Microsoft.DurableTask.Entities;
331+
332+
namespace MyNS
333+
{{
334+
[DurableTask(nameof(MyEntity))]
335+
public class MyEntity : TaskEntity<{stateType}>
336+
{{
337+
public {stateType} Get() => this.State;
338+
}}
339+
}}";
340+
341+
string expectedOutput = TestHelpers.WrapAndFormat(
342+
GeneratedClassName,
343+
methodList: @"
344+
[Function(nameof(MyEntity))]
345+
public static Task MyEntity([EntityTrigger] TaskEntityDispatcher dispatcher)
346+
{
347+
return dispatcher.DispatchAsync<MyNS.MyEntity>();
348+
}",
349+
isDurableFunctions: true);
350+
351+
await TestHelpers.RunTestAsync<DurableTaskSourceGenerator>(
352+
GeneratedFileName,
353+
code,
354+
expectedOutput,
355+
isDurableFunctions: true);
356+
}
357+
358+
/// <summary>
359+
/// Verifies that using the class-based syntax for authoring entities with inheritance generates
360+
/// <see cref="EntityTriggerAttribute"/> function triggers for Azure Functions.
361+
/// </summary>
362+
/// <param name="stateType">The entity state type.</param>
363+
[Theory]
364+
[InlineData("int")]
365+
[InlineData("string")]
366+
public async Task Entities_ClassBasedSyntax_Inheritance(string stateType)
367+
{
368+
string code = $@"
369+
#nullable enable
370+
using System.Threading.Tasks;
371+
using Microsoft.DurableTask;
372+
using Microsoft.DurableTask.Entities;
373+
374+
namespace MyNS
375+
{{
376+
[DurableTask]
377+
public class MyEntity : MyEntityBase
378+
{{
379+
public override {stateType} Get() => this.State;
380+
}}
381+
382+
public abstract class MyEntityBase : TaskEntity<{stateType}>
383+
{{
384+
public abstract {stateType} Get();
385+
}}
386+
}}";
387+
388+
string expectedOutput = TestHelpers.WrapAndFormat(
389+
GeneratedClassName,
390+
methodList: @"
391+
[Function(nameof(MyEntity))]
392+
public static Task MyEntity([EntityTrigger] TaskEntityDispatcher dispatcher)
393+
{
394+
return dispatcher.DispatchAsync<MyNS.MyEntity>();
395+
}",
396+
isDurableFunctions: true);
397+
398+
await TestHelpers.RunTestAsync<DurableTaskSourceGenerator>(
399+
GeneratedFileName,
400+
code,
401+
expectedOutput,
402+
isDurableFunctions: true);
403+
}
404+
405+
/// <summary>
406+
/// Verifies that using the class-based syntax for authoring entities with custom state types generates
407+
/// <see cref="EntityTriggerAttribute"/> function triggers for Azure Functions.
408+
/// </summary>
409+
[Fact]
410+
public async Task Entities_ClassBasedSyntax_CustomStateType()
411+
{
412+
string code = @"
413+
#nullable enable
414+
using System.Threading.Tasks;
415+
using Microsoft.DurableTask;
416+
using Microsoft.DurableTask.Entities;
417+
418+
namespace MyNS
419+
{
420+
public class MyState
421+
{
422+
public int Value { get; set; }
423+
}
424+
425+
[DurableTask(nameof(MyEntity))]
426+
public class MyEntity : TaskEntity<MyState>
427+
{
428+
public MyState Get() => this.State;
429+
}
430+
}";
431+
432+
string expectedOutput = TestHelpers.WrapAndFormat(
433+
GeneratedClassName,
434+
methodList: @"
435+
[Function(nameof(MyEntity))]
436+
public static Task MyEntity([EntityTrigger] TaskEntityDispatcher dispatcher)
437+
{
438+
return dispatcher.DispatchAsync<MyNS.MyEntity>();
439+
}",
440+
isDurableFunctions: true);
441+
442+
await TestHelpers.RunTestAsync<DurableTaskSourceGenerator>(
443+
GeneratedFileName,
444+
code,
445+
expectedOutput,
446+
isDurableFunctions: true);
447+
}
448+
449+
/// <summary>
450+
/// Verifies that using the class-based syntax for authoring a mix of orchestrators, activities,
451+
/// and entities generates the appropriate function triggers for Azure Functions.
452+
/// </summary>
453+
[Fact]
454+
public async Task Mixed_OrchestratorActivityEntity_ClassBasedSyntax()
455+
{
456+
string code = @"
457+
#nullable enable
458+
using System;
459+
using System.Threading.Tasks;
460+
using Microsoft.DurableTask;
461+
using Microsoft.DurableTask.Entities;
462+
463+
namespace MyNS
464+
{
465+
[DurableTask(nameof(MyOrchestrator))]
466+
public class MyOrchestrator : TaskOrchestrator<int, string>
467+
{
468+
public override Task<string> RunAsync(TaskOrchestrationContext ctx, int input) => Task.FromResult(string.Empty);
469+
}
470+
471+
[DurableTask(nameof(MyActivity))]
472+
public class MyActivity : TaskActivity<int, string>
473+
{
474+
public override Task<string> RunAsync(TaskActivityContext context, int input) => Task.FromResult(string.Empty);
475+
}
476+
477+
[DurableTask(nameof(MyEntity))]
478+
public class MyEntity : TaskEntity<int>
479+
{
480+
public int Get() => this.State;
481+
}
482+
}";
483+
484+
string expectedOutput = TestHelpers.WrapAndFormat(
485+
GeneratedClassName,
486+
methodList: $@"
487+
static readonly ITaskOrchestrator singletonMyOrchestrator = new MyNS.MyOrchestrator();
488+
489+
[Function(nameof(MyOrchestrator))]
490+
public static Task<string> MyOrchestrator([OrchestrationTrigger] TaskOrchestrationContext context)
491+
{{
492+
return singletonMyOrchestrator.RunAsync(context, context.GetInput<int>())
493+
.ContinueWith(t => (string)(t.Result ?? default(string)!), TaskContinuationOptions.ExecuteSynchronously);
494+
}}
495+
496+
/// <inheritdoc cref=""IOrchestrationSubmitter.ScheduleNewOrchestrationInstanceAsync""/>
497+
public static Task<string> ScheduleNewMyOrchestratorInstanceAsync(
498+
this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null)
499+
{{
500+
return client.ScheduleNewOrchestrationInstanceAsync(""MyOrchestrator"", input, options);
501+
}}
502+
503+
/// <inheritdoc cref=""TaskOrchestrationContext.CallSubOrchestratorAsync(TaskName, object?, TaskOptions?)""/>
504+
public static Task<string> CallMyOrchestratorAsync(
505+
this TaskOrchestrationContext context, int input, TaskOptions? options = null)
506+
{{
507+
return context.CallSubOrchestratorAsync<string>(""MyOrchestrator"", input, options);
508+
}}
509+
510+
public static Task<string> CallMyActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null)
511+
{{
512+
return ctx.CallActivityAsync<string>(""MyActivity"", input, options);
513+
}}
514+
515+
[Function(nameof(MyActivity))]
516+
public static async Task<string> MyActivity([ActivityTrigger] int input, string instanceId, FunctionContext executionContext)
517+
{{
518+
ITaskActivity activity = ActivatorUtilities.GetServiceOrCreateInstance<MyNS.MyActivity>(executionContext.InstanceServices);
519+
TaskActivityContext context = new GeneratedActivityContext(""MyActivity"", instanceId);
520+
object? result = await activity.RunAsync(context, input);
521+
return (string)result!;
522+
}}
523+
524+
[Function(nameof(MyEntity))]
525+
public static Task MyEntity([EntityTrigger] TaskEntityDispatcher dispatcher)
526+
{{
527+
return dispatcher.DispatchAsync<MyNS.MyEntity>();
528+
}}
529+
{TestHelpers.DeIndent(DurableTaskSourceGenerator.GetGeneratedActivityContextCode(), spacesToRemove: 8)}",
530+
isDurableFunctions: true);
531+
532+
await TestHelpers.RunTestAsync<DurableTaskSourceGenerator>(
533+
GeneratedFileName,
534+
code,
535+
expectedOutput,
536+
isDurableFunctions: true);
537+
}
315538
}

0 commit comments

Comments
 (0)