Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"recommendations": [
"humao.rest-client",
"ms-dotnettools.csharp",
"ms-azuretools.vscode-azurefunctions"
]
}
14 changes: 14 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "AzureFunctionsApp",
"type": "coreclr",
"request": "attach",
"processId": "${command:azureFunctions.pickProcess}"
}
]
}
33 changes: 28 additions & 5 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,40 @@
"args": [
"build",
"${workspaceRoot}/Microsoft.DurableTask.sln",
// Ask dotnet build to generate full paths for file names.
"/property:GenerateFullPaths=true",
// Do not generate summary otherwise it leads to duplicate errors in Problems panel
"/consoleloggerparameters:NoSummary"
],
"label": "build",
"group": "build",
"presentation": {
"reveal": "silent"
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": "$msCompile"
},
{
"label": "build (AzureFunctionsApp)",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/samples/AzureFunctionsApp",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"type": "shell",
"group": {
"kind": "build"
},
"problemMatcher": "$msCompile",
},
{
"type": "func",
"dependsOn": "build (AzureFunctionsApp)",
"options": {
"cwd": "${workspaceFolder}/out/samples/bin/Debug/AzureFunctionsApp/net6.0"
},
"command": "host start",
"isBackground": true,
"problemMatcher": "$func-dotnet-watch"
}
]
}
172 changes: 144 additions & 28 deletions samples/AzureFunctionsApp/Entities/Counter.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Net;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.DurableTask;
Expand All @@ -10,6 +11,20 @@

namespace AzureFunctionsApp.Entities;

/**
* The counter example shows the 3 different ways to dispatch to an entity.
* The mode query string is what controls this:
* mode=0 or mode=entity (default) - dispatch to "counter" entity
* mode=1 or mode=state - dispatch to "counter_state" entity
* mode=2 or mode=static - dispatch to "counter_alt" entity
*
* "counter" and "counter_alt" are the same entities, however they use
* two different functions to dispatch, and thus are different entities when
* persisted in the backend.
*
* See "counters.http" file for HTTP examples.
*/

/// <summary>
/// Example on how to dispatch to an entity which directly implements TaskEntity<TState>. Using TaskEntity<TState> gives
/// the added benefit of being able to use DI. When using TaskEntity<TState>, state is deserialized to the "State"
Expand All @@ -32,6 +47,8 @@ public int Add(int input)

public int Get() => this.State;

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

[Function("Counter")]
public Task DispatchAsync([EntityTrigger] TaskEntityDispatcher dispatcher)
{
Expand Down Expand Up @@ -67,12 +84,15 @@ public class StateCounter

public int Get() => this.Value;

public void Reset() => this.Value = 0;

[Function("Counter_State")]
public static Task DispatchAsync([EntityTrigger] TaskEntityDispatcher dispatcher)
{
// Using the dispatch to a state object will deserialize the state directly to that instance and dispatch to an
// appropriate method.
// Can only dispatch to a state object via generic argument.
// "state object" is defined as any type which does not implement ITaskEntity.
return dispatcher.DispatchAsync<StateCounter>();
}
}
Expand All @@ -82,26 +102,136 @@ public static class CounterApis
/// <summary>
/// Usage:
/// Add to <see cref="Counter"/>:
/// POST /api/counter/{id}?value={value-to-add}
/// POST /api/counter/{id}?value={value-to-add}&mode=0
/// POST /api/counter/{id}?value={value-to-add}&mode=entity
/// POST /api/counters/{id}/add/{value}
/// POST /api/counters/{id}/add/{value}?&mode=0
/// POST /api/counters/{id}/add/{value}?&mode=entity
///
/// Add to <see cref="StateCounter"/>
/// POST /api/counter/{id}?value={value-to-add}&mode=1
/// POST /api/counter/{id}?value={value-to-add}&mode=state
/// POST /api/counters/{id}/add/{value}?&mode=1
/// POST /api/counters/{id}/add/{value}?&mode=state
///
/// Add to <see cref="Counter"/>, using the static method.
/// POST /api/counters/{id}/add/{value}?&mode=2
/// POST /api/counters/{id}/add/{value}?&mode=static
/// </summary>
/// <param name="request"></param>
/// <param name="client"></param>
/// <param name="id"></param>
/// <returns></returns>
[Function("StartCounter")]
public static async Task<HttpResponseData> StartAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "counter/{id}")] HttpRequestData request,
[Function("Counter_Add")]
public static async Task<HttpResponseData> AddAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "counters/{id}/add/{value}")] HttpRequestData request,
[DurableClient] DurableTaskClient client,
string id,
int value)
{
EntityInstanceId entityId = GetEntityId(request, id);
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(
"CounterOrchestration", new Payload(entityId, value));
return client.CreateCheckStatusResponse(request, instanceId);
}

/// <summary>
/// Usage:
/// Add to <see cref="Counter"/>:
/// GET /api/counters/{id}
/// GET /api/counters/{id}?&mode=0
/// GET /api/counters/{id}?&mode=entity
///
/// Add to <see cref="StateCounter"/>
/// GET /api/counters/{id}?&mode=1
/// GET /api/counters/{id}?&mode=state
///
/// Add to <see cref="Counter"/>, using the static method.
/// GET /api/counters/{id}?&mode=2
/// GET /api/counters/{id}?&mode=static
/// </summary>
[Function("Counter_Get")]
public static async Task<HttpResponseData> GetAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "counters/{id}")] HttpRequestData request,
[DurableClient] DurableTaskClient client,
string id)
{
_ = int.TryParse(request.Query["value"], out int value);
EntityInstanceId entityId = GetEntityId(request, id);

// "counter_state" corresponds to StateCounter, which has a different state structure than the other modes.
// The following calls highlight how entity vs state dispatch changes the state structure of your entity.
object? entity = entityId.Name == "counter_state"
? await client.Entities.GetEntityAsync<StateCounter>(entityId)
: await client.Entities.GetEntityAsync<int>(entityId);
if (entity is null)
{
return request.CreateResponse(HttpStatusCode.NotFound);
}

// We serialize the entire entity to show the structural differences.
HttpResponseData response = request.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(entity);
return response;
}

/// <summary>
/// Usage:
/// Add to <see cref="Counter"/>:
/// DELETE /api/counters/{id}
/// DELETE /api/counters/{id}?&mode=0
/// DELETE /api/counters/{id}?&mode=entity
///
/// Add to <see cref="StateCounter"/>
/// DELETE /api/counters/{id}?&mode=1
/// DELETE /api/counters/{id}?&mode=state
///
/// Add to <see cref="Counter"/>, using the static method.
/// DELETE /api/counters/{id}?&mode=2
/// DELETE /api/counters/{id}?&mode=static
/// </summary>
[Function("Counter_Delete")]
public static async Task<HttpResponseData> DeleteAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "counters/{id}")] HttpRequestData request,
[DurableClient] DurableTaskClient client,
string id)
{
EntityInstanceId entityId = GetEntityId(request, id);
await client.Entities.SignalEntityAsync(entityId, "delete");
return request.CreateResponse(HttpStatusCode.Accepted);
}

/// <summary>
/// Usage:
/// Add to <see cref="Counter"/>:
/// POST /api/counters/{id}/reset
/// POST /api/counters/{id}/reset?&mode=0
/// POST /api/counters/{id}/reset?&mode=entity
///
/// Add to <see cref="StateCounter"/>
/// POST /api/counters/{id}/reset?&mode=1
/// POST /api/counters/{id}/reset?&mode=state
///
/// Add to <see cref="Counter"/>, using the static method.
/// POST /api/counters/{id}/reset?&mode=2
/// POST /api/counters/{id}/reset?&mode=static
/// </summary>
[Function("Counter_Reset")]
public static async Task<HttpResponseData> ResetAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "counters/{id}/reset")] HttpRequestData request,
[DurableClient] DurableTaskClient client,
string id)
{
EntityInstanceId entityId = GetEntityId(request, id);
await client.Entities.SignalEntityAsync(entityId, "reset");
return request.CreateResponse(HttpStatusCode.Accepted);
}

[Function("CounterOrchestration")]
public static async Task<int> RunOrchestrationAsync(
[OrchestrationTrigger] TaskOrchestrationContext context, Payload input)
{
ILogger logger = context.CreateReplaySafeLogger<Counter>();
int result = await context.Entities.CallEntityAsync<int>(input.Id, "add", input.Add);
logger.LogInformation("Counter value: {Value}", result);
return result;
}

public record Payload(EntityInstanceId Id, int Add);

static EntityInstanceId GetEntityId(HttpRequestData request, string key)
{
// switch to Counter_State if ?mode=1 or ?mode=state is supplied.
// or to Counter_Alt if ?mode=2 or ?mode=static is supplied.
string name;
Expand All @@ -125,20 +255,6 @@ public static async Task<HttpResponseData> StartAsync(
};
}

string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(
"CounterOrchestration", new Payload(new EntityInstanceId(name, id), value));
return client.CreateCheckStatusResponse(request, instanceId);
}

[Function("CounterOrchestration")]
public static async Task<int> RunOrchestrationAsync(
[OrchestrationTrigger] TaskOrchestrationContext context, Payload input)
{
ILogger logger = context.CreateReplaySafeLogger<Counter>();
int result = await context.Entities.CallEntityAsync<int>(input.Id, "add", input.Add);
logger.LogInformation("Counter value: {Value}", result);
return result;
return new(name, key);
}

public record Payload(EntityInstanceId Id, int Add);
}
40 changes: 24 additions & 16 deletions samples/AzureFunctionsApp/Entities/Lifetime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ namespace AzureFunctionsApp.Entities;

/// <summary>
/// Example showing the lifetime of an entity. An entity is initialized on the first operation it receives and then
/// is considered deleted when <see cref="TaskEntity{TState}.State"/> is <c>null</c> at the end of an operation.
/// is considered deleted when <see cref="TaskEntity{TState}.State"/> is <c>null</c> at the end of an operation. It
/// is also possible to design an entity which remains stateless by always returning <c>null</c> from
/// <see cref="InitializeState"/> and never assigning a non-null state.
/// </summary>
public class Lifetime : TaskEntity<MyState>
{
Expand All @@ -25,8 +27,10 @@ public Lifetime(ILogger<Lifetime> logger)
}

/// <summary>
/// Optional property to override. When 'true', this will allow dispatching of operations to the TState object if
/// there is no matching method on the entity. Default is 'false'.
/// Optional property to override. When 'true', this will allow dispatching of operations to the <see cref="State">
/// object if there is no matching method on the entity. Default is 'false'.
/// Stated differently: if this is <c>true</c> and there is no matching method for a given operation on the entity
/// type, then the operation will attempt to find a matching method on <typeparamref name="TState"/> instead.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised to see this as a feature since I don't recall it ever being discussed. What are the use-cases? I'm also interested to understand the lifetime implications, etc.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was to help migration so customers don't have to derive from ITaskEntity if they do not want to.

What lifetime implications are you concerned about?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering what happens if the state object is null, etc. I don't recall any such feature existing for the in-proc model. But maybe I'm misunderstanding what is being referred to by TState? Is that not the entity state?

Copy link
Member Author

@jviau jviau Oct 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In proc model does not have a concept of ITaskEntity base class. This is here to align closer with how inproc behaves: you can dispatch entity operations to some POCO. We have designed it so that POCO dispatching is done through the TState here.

If state is null we will use Activator.CreateInstance.

Copy link
Member

@sebastianburckhardt sebastianburckhardt Oct 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying to summarize the discussions and design a bit:

For in-proc we had two models:

  • class-based. Entity state must be defined by a class, and operations are dispatched to methods.
  • function-based. Very low level but totally customizable, with entity state being an arbitrary type.

For isolated we have three (but only the first two are expected to be widely used):

  • class-based. Pretty much the same as for in-proc.
  • TaskEntity<TState>. Similar to class-based but with one layer of indirection, which allows advanced scenarios (arbitrary TState types, dependency injection, customizable code for constructing the state) without having to give up the convenience of the class-based approach.
  • ITaskEntity. Very similar to function-based. Very general but verbose. Not recommended unless you are building some kind of framework on top of entities.

/// </summary>
protected override bool AllowStateDispatch => base.AllowStateDispatch;

Expand All @@ -39,12 +43,16 @@ public Task DispatchAsync([EntityTrigger] TaskEntityDispatcher dispatcher)
return dispatcher.DispatchAsync(this);
}

public void Init() { } // no op just to init this entity.
public MyState Get() => this.State;

public void Init() { } // no op just to initialize this entity.

public void CustomDelete()
{
// Deleting an entity is done by null-ing out the state.
// The '!' in `null!;` is only needed because we are using C# explicit nullability.
// This method shows that entity deletion can be accomplished from any operation by nulling out the state. The
// operation does not have to be named "delete". The only requirement for deletion is that state is <c>null</c>
// when the operation returns.
// The '!' in `null!;` is only needed because C# explicit nullability is enabled.
// This can be avoided by either:
// 1) Declare TaskEntity<MyState?> instead.
// 2) Disable explicit nullability.
Expand All @@ -55,24 +63,24 @@ public void Delete()
{
// Entities have an implicit 'delete' operation when there is no matching 'delete' method. By explicitly adding
// a 'Delete' method, it will override the implicit 'delete' operation.
// Since state deletion is determined by nulling out `this.State`, it means that value-types cannot be deleted
// except by the implicit delete (which will still delete it). To manually delete a value-type, you can declare
// it as nullable. Such as TaskEntity<int?> instead of TaskEntity<int>.
// Since state deletion is determined by nulling out <c>this.State</c>, it means that value-types cannot be
// deleted except by the implicit delete (which will still delete it). To manually delete a value-type, the
// state can be declared as nullable. Such as TaskEntity<int?> instead of TaskEntity<int>.
this.State = null!;
}

protected override MyState InitializeState(TaskEntityOperation operation)
{
// This method allows for customizing the default state value for a new entity.
return new("Default", 10);
return new(Guid.NewGuid().ToString("N"), Random.Shared.Next(0, 1000));
}
}

public static class LifetimeApis
{
[Function("GetLifetime")]
[Function("Lifetime_Get")]
public static async Task<HttpResponseData> GetAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "state/{id}")] HttpRequestData request,
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "lifetimes/{id}")] HttpRequestData request,
[DurableClient] DurableTaskClient client,
string id)
{
Expand All @@ -89,19 +97,19 @@ public static async Task<HttpResponseData> GetAsync(
return response;
}

[Function("InitLifetime")]
[Function("Lifetime_Init")]
public static async Task<HttpResponseData> InitAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "state/{id}")] HttpRequestData request,
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "lifetimes/{id}")] HttpRequestData request,
[DurableClient] DurableTaskClient client,
string id)
{
await client.Entities.SignalEntityAsync(new EntityInstanceId(nameof(Lifetime), id), "init");
return request.CreateResponse(HttpStatusCode.Accepted);
}

[Function("DeleteLifetime")]
[Function("Lifetime_Delete")]
public static async Task<HttpResponseData> DeleteAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "state/{id}")] HttpRequestData request,
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "lifetimes/{id}")] HttpRequestData request,
[DurableClient] DurableTaskClient client,
string id)
{
Expand Down
Loading