diff --git a/samples/isolated-entities/Chirper/.gitignore b/samples/isolated-entities/Chirper/.gitignore new file mode 100644 index 000000000..1422b39dc --- /dev/null +++ b/samples/isolated-entities/Chirper/.gitignore @@ -0,0 +1,13 @@ +# Build output +bin/ +obj/ + +# User-specific files +*.user +*.suo + +# Azure Functions local settings +local.settings.json + +# Visual Studio cache +.vs/ \ No newline at end of file diff --git a/samples/isolated-entities/Chirper/Chirper.csproj b/samples/isolated-entities/Chirper/Chirper.csproj new file mode 100644 index 000000000..dca8b42fe --- /dev/null +++ b/samples/isolated-entities/Chirper/Chirper.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + + + + + diff --git a/samples/isolated-entities/Chirper/Entities/IUserChirps.cs b/samples/isolated-entities/Chirper/Entities/IUserChirps.cs new file mode 100644 index 000000000..bcc4c7954 --- /dev/null +++ b/samples/isolated-entities/Chirper/Entities/IUserChirps.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace Chirper +{ + public interface IUserChirps + { + void Add(Chirp chirp); + + void Remove(DateTime timestamp); + + Task> Get(); + } +} diff --git a/samples/isolated-entities/Chirper/Entities/IUserFollows.cs b/samples/isolated-entities/Chirper/Entities/IUserFollows.cs new file mode 100644 index 000000000..6e7222285 --- /dev/null +++ b/samples/isolated-entities/Chirper/Entities/IUserFollows.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace Chirper +{ + public interface IUserFollows + { + void Add(string user); + + void Remove(string user); + + Task> Get(); + } +} diff --git a/samples/isolated-entities/Chirper/Entities/UserChirps.cs b/samples/isolated-entities/Chirper/Entities/UserChirps.cs new file mode 100644 index 000000000..1ceef9284 --- /dev/null +++ b/samples/isolated-entities/Chirper/Entities/UserChirps.cs @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Microsoft.Azure.Functions.Worker; +using Newtonsoft.Json; + +namespace Chirper +{ + // The UserChirps entity stores all the chirps by ONE user. + // The entity key is the userId. + + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + public class UserChirps : IUserChirps + { + [JsonProperty] + public List Chirps { get; set; } = new List(); + + public void Add(Chirp chirp) + { + Chirps.Add(chirp); + } + + public void Remove(DateTime timestamp) + { + Chirps.RemoveAll(chirp => chirp.Timestamp == timestamp); + } + + public Task> Get() + { + return Task.FromResult(Chirps); + } + + // Boilerplate (entry point for the functions runtime) + [Function(nameof(UserChirps))] + public static Task HandleEntityOperation([EntityTrigger] TaskEntityDispatcher context) + { + return context.DispatchAsync(); + } + } +} \ No newline at end of file diff --git a/samples/isolated-entities/Chirper/Entities/UserFollows.cs b/samples/isolated-entities/Chirper/Entities/UserFollows.cs new file mode 100644 index 000000000..93e4efd27 --- /dev/null +++ b/samples/isolated-entities/Chirper/Entities/UserFollows.cs @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Microsoft.Azure.Functions.Worker; +using Newtonsoft.Json; + +namespace Chirper +{ + // The UserFollows entity stores all the follows of ONE user. + // The entity key is the userId. + + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + public class UserFollows : IUserFollows + { + [JsonProperty] + public List FollowedUsers { get; set; } = new List(); + + public void Add(string user) + { + FollowedUsers.Add(user); + } + + public void Remove(string user) + { + FollowedUsers.Remove(user); + } + + public Task> Get() + { + return Task.FromResult(FollowedUsers); + } + + // Boilerplate (entry point for the functions runtime) + [Function(nameof(UserFollows))] + public static Task HandleEntityOperation([EntityTrigger] TaskEntityDispatcher context) + { + return context.DispatchAsync(); + } + } +} \ No newline at end of file diff --git a/samples/isolated-entities/Chirper/Orchestrations/GetTimeline.cs b/samples/isolated-entities/Chirper/Orchestrations/GetTimeline.cs new file mode 100644 index 000000000..a7392fd8b --- /dev/null +++ b/samples/isolated-entities/Chirper/Orchestrations/GetTimeline.cs @@ -0,0 +1,44 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; + +namespace Chirper +{ + // The GetTimeline orchestration collects all chirps by followed users, + // and returns it as a list sorted by timestamp. + public static class GetTimeline + { + [Function(nameof(GetTimeline))] + public static async Task RunOrchestrator( + [OrchestrationTrigger] TaskOrchestrationContext context) + { + var userId = context.GetInput(); + + // call the UserFollows entity to figure out whose chirps should be included + var userFollowsId = new EntityInstanceId(nameof(UserFollows), userId); + var followedUsers = await context.Entities.CallEntityAsync>(userFollowsId, "Get"); + + + // in parallel, collect all the chirps + var tasks = followedUsers + .Select(id => + { + var userChirpsId = new EntityInstanceId(nameof(UserChirps), id); + return context.Entities.CallEntityAsync>(userChirpsId, "Get"); + }) + .ToList(); + + await Task.WhenAll(tasks); + + // combine and sort the returned lists of chirps + var sortedResults = tasks + .SelectMany(task => task.Result) + .OrderBy(chirp => chirp.Timestamp); + + return sortedResults.ToArray(); + } + } +} \ No newline at end of file diff --git a/samples/isolated-entities/Chirper/Program.cs b/samples/isolated-entities/Chirper/Program.cs new file mode 100644 index 000000000..704475c64 --- /dev/null +++ b/samples/isolated-entities/Chirper/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var builder = FunctionsApplication.CreateBuilder(args); + +builder.ConfigureFunctionsWebApplication(); + +builder.Services + .AddApplicationInsightsTelemetryWorkerService() + .ConfigureFunctionsApplicationInsights(); + +builder.Build().Run(); diff --git a/samples/isolated-entities/Chirper/Properties/serviceDependencies.json b/samples/isolated-entities/Chirper/Properties/serviceDependencies.json new file mode 100644 index 000000000..df4dcc9d8 --- /dev/null +++ b/samples/isolated-entities/Chirper/Properties/serviceDependencies.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "appInsights1": { + "type": "appInsights" + }, + "storage1": { + "type": "storage", + "connectionId": "AzureWebJobsStorage" + } + } +} \ No newline at end of file diff --git a/samples/isolated-entities/Chirper/Properties/serviceDependencies.local.json b/samples/isolated-entities/Chirper/Properties/serviceDependencies.local.json new file mode 100644 index 000000000..b804a2893 --- /dev/null +++ b/samples/isolated-entities/Chirper/Properties/serviceDependencies.local.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "appInsights1": { + "type": "appInsights.sdk" + }, + "storage1": { + "type": "storage.emulator", + "connectionId": "AzureWebJobsStorage" + } + } +} \ No newline at end of file diff --git a/samples/isolated-entities/Chirper/PublicRest/Chirp.cs b/samples/isolated-entities/Chirper/PublicRest/Chirp.cs new file mode 100644 index 000000000..92c4693e1 --- /dev/null +++ b/samples/isolated-entities/Chirper/PublicRest/Chirp.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Chirper +{ + /// + /// A data structure representing a chirp. + /// + [JsonObject(MemberSerialization.OptOut)] + public struct Chirp + { + public string UserId { get; set; } + + public DateTime Timestamp { get; set; } + + public string Content { get; set; } + } +} diff --git a/samples/isolated-entities/Chirper/PublicRest/HttpSurface.cs b/samples/isolated-entities/Chirper/PublicRest/HttpSurface.cs new file mode 100644 index 000000000..720d7e7c6 --- /dev/null +++ b/samples/isolated-entities/Chirper/PublicRest/HttpSurface.cs @@ -0,0 +1,145 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Client.Entities; +using Microsoft.DurableTask.Entities; +using Microsoft.Extensions.Logging; + +namespace Chirper +{ + public static class HttpSurface + { + [Function("UserTimelineGet")] + public static async Task UserTimelineGet( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "user/{userId}/timeline")] HttpRequestData req, + [DurableClient] DurableTaskClient client, + ILogger log, + string userId) + { + Authenticate(req, userId); + var instanceId = await client.ScheduleNewOrchestrationInstanceAsync(nameof(GetTimeline), userId); + return await client.CreateCheckStatusResponseAsync(req, instanceId); + } + + [Function("UserChirpsGet")] + public static async Task UserChirpsGet( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "user/{userId}/chirps")] HttpRequestData req, + [DurableClient] DurableTaskClient client, + ILogger log, + string userId) + { + Authenticate(req, userId); + var target = new EntityInstanceId(nameof(UserChirps), userId); + var chirps = await client.Entities.GetEntityAsync(target); + + if (chirps != null && chirps.State != null) + { + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(chirps.State.Chirps); + return response; + } + + var notFoundResponse = req.CreateResponse(HttpStatusCode.NotFound); + return notFoundResponse; + } + + [Function("UserChirpsPost")] + public static async Task UserChirpsPost( + [HttpTrigger(AuthorizationLevel.Function, "post", Route = "user/{userId}/chirps")] HttpRequestData req, + [DurableClient] DurableTaskClient client, + ILogger log, + string userId) + { + Authenticate(req, userId); + var chirp = new Chirp() + { + UserId = userId, + Timestamp = DateTime.UtcNow, + Content = await req.ReadAsStringAsync() ?? string.Empty, + }; + + var entityInstanceId = new EntityInstanceId(nameof(UserChirps), userId); + await client.Entities.SignalEntityAsync(entityInstanceId, "Add", chirp); + + var response = req.CreateResponse(HttpStatusCode.Accepted); + await response.WriteAsJsonAsync(chirp); + + return response; + } + + [Function("UserChirpsDelete")] + public static async Task UserChirpsDelete( + [HttpTrigger(AuthorizationLevel.Function, "delete", Route = "user/{userId}/chirps/{timestamp}")] HttpRequestData req, + [DurableClient] DurableTaskClient client, + ILogger log, + string userId, + DateTime timestamp) + { + Authenticate(req, userId); + + var entityInstanceId = new EntityInstanceId(nameof(UserChirps), userId); + await client.Entities.SignalEntityAsync(entityInstanceId, "Remove", timestamp); + + return req.CreateResponse(HttpStatusCode.Accepted); + } + + [Function("UserFollowsGet")] + public static async Task UserFollowsGet( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "user/{userId}/follows")] HttpRequestData req, + [DurableClient] DurableTaskClient client, + ILogger log, + string userId) + { + Authenticate(req, userId); + var target = new EntityInstanceId(nameof(UserFollows), userId); + EntityMetadata? follows = await client.Entities.GetEntityAsync(target); + + if (follows != null) + { + HttpResponseData response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(follows.State.FollowedUsers); + return response; + } + + return req.CreateResponse(HttpStatusCode.NotFound); + } + + [Function("UserFollowsPost")] + public static async Task UserFollowsPost( + [HttpTrigger(AuthorizationLevel.Function, "post", Route = "user/{userId}/follows/{userId2}")] HttpRequestData req, + [DurableClient] DurableTaskClient client, + ILogger log, + string userId, + string userId2) + { + Authenticate(req, userId); + var entityInstanceId = new EntityInstanceId(nameof(UserFollows), userId); + await client.Entities.SignalEntityAsync(entityInstanceId, "Add", userId2); + return req.CreateResponse(HttpStatusCode.Accepted); + } + + [Function("UserFollowsDelete")] + public static async Task UserFollowsDelete( + [HttpTrigger(AuthorizationLevel.Function, "delete", Route = "user/{userId}/follows/{userId2}")] HttpRequestData req, + [DurableClient] DurableTaskClient client, + ILogger log, + string userId, + string userId2) + { + Authenticate(req, userId); + var content = req.Body.ToString(); + var entityInstanceId = new EntityInstanceId(nameof(UserFollows), userId); + await client.Entities.SignalEntityAsync(entityInstanceId, "Remove", userId2); + return req.CreateResponse(HttpStatusCode.Accepted); + } + + private static void Authenticate(HttpRequestData request, string userId) + { + // Stub: validate that the request is coming from this userId + } + } +} diff --git a/samples/isolated-entities/Chirper/README.md b/samples/isolated-entities/Chirper/README.md new file mode 100644 index 000000000..9a0312be0 --- /dev/null +++ b/samples/isolated-entities/Chirper/README.md @@ -0,0 +1,104 @@ +# Chirper Sample + +This sample demonstrates how to create a simple, stateful, serverless REST service using +Azure Functions and Durable Functions .NET Isolated. + +The users of this service can: + +1. post messages (called "chirps") to their account, or delete them +2. follow or unfollow other users +3. view a timeline containing all chirps of the people they are following, sorted by timestamp + +This sample is meant to highlight support for stateful entities and how they +can be used in conjunction with Durable Orchestrations. It is based on +[sample code from a paper](https://www.microsoft.com/en-us/research/publication/reactive-caching-for-composed-services/), +which was itself inspired by the Chirper virtual actor sample included with the +[Orleans framework](https://github.com/dotnet/orleans). + + +## Design + +The function application contains a total of 10 Azure Functions. + +Two of the functions are *durable entities*, implementing the stateful components. + + - The **UserChirps** entity stores a list of chirps, representing the chirps by a particular user. + There is one UserChirps entity per user: The entity key is the userId. The operations are *Add* (adds a chirp), + *Remove* (removes a chirp) and *Get* (returns the list of chirps). + + - The **UserFollows** entity stores the set of users that a particular user is following. + There is one UserFollows entity per user: The entity key is the userId. The operations are *Add* (follows a user), + *Remove* (unfollows a user) and *Get* (returns the list of followed users). + +One of the functions is a *durable orchestration*, implementing the timeline query. + + - The **GetTimeline** orchestration collects the chirps for the timeline of a particular user. + It first calls the `UserFollows` entity to get a list of the followed users. Then it calls the UserChirps + entities of all the followed users, *in parallel*. Once it receives all the lists + it combines them and sorts them. + +Seven of the functions are Http triggers that implement the REST interface (see next section for a list). Each of them specifies a path, and uses +the IDurableOrchestrationClient to access the durable entities and durable orchestration. + + - The POST methods signal the respective entities, and returns 202. + - The GET methods for chirps or follows read the entity state . + - The GET method for the timeline calls the GetTimelineOrchestration and returns either the result, + or a 202 including an URL for status. + + +## Running The Sample Locally with VS + +Open Chirper.sln in Visual Studio, compile, and run. + +On Windows, this automatically starts the local development storage. +On macOS, you can edit the `local.settings.json` and replace `UseDevelopmentStorage=true` with a connection string to an Azure storage account. + +Once the function runtime starts successfully, the console shows the progress. +After some time it prints a list of the HTTP bindings: + + UserChirpsDelete: [DELETE] http://localhost:7071/api/user/{userId}/chirps/{timestamp} + UserChirpsGet: [GET] http://localhost:7071/api/user/{userId}/chirps + UserChirpsPost: [POST] http://localhost:7071/api/user/{userId}/chirps + UserFollowsDelete: [DELETE] http://localhost:7071/api/user/{userId}/follows/{userId2} + UserFollowsGet: [GET] http://localhost:7071/api/user/{userId}/follows + UserFollowsPost: [POST] http://localhost:7071/api/user/{userId}/follows/{userId2} + UserTimelineGet: [GET] http://localhost:7071/api/user/{userId}/timeline + + +You can now use [curl](https://github.com/curl/curl) (or any other tool that lets you compose HTTP requests) +to test the chirper service via these endpoints. + +### Sample interaction + +For example, let's say Alice adds three chirps using POST: + + curl -d "Alice's first message" http://localhost:7071/api/user/alice/chirps -H Content-Type:application/text + curl -d "Alice's second message" http://localhost:7071/api/user/alice/chirps -H Content-Type:application/text + curl -d "Alice's third message" http://localhost:7071/api/user/alice/chirps -H Content-Type:application/text + +We can then query Alice's chirps using GET: + + curl http://localhost:7071/user/alice/chirps + +which returns a JSON representation of all the chirps by Alice, including timestamps: + + [{"userId":"alice","timestamp":"2019-05-01T15:45:42.2223472Z","content":"Alice's first message"},{"userId":"alice","timestamp":"2019-05-01T15:45:44.7693918Z","content":"Alice's second message"},{"userId":"alice","timestamp":"2019-05-01T15:45:45.7658774Z","content":"Alice's third message"}] + +Let's add some more messages by other users: + + curl -d "Bob's first message" http://localhost:7071/api/user/bob/chirps -H Content-Type:application/text + curl -d "Charlie's first message" http://localhost:7071/api/user/charlie/chirps -H Content-Type:application/text + curl -d "Bob's second message" http://localhost:7071/api/user/bob/chirps -H Content-Type:application/text + +Then, let's say Doris wants to follow Alice, Bob, and Charlie + + curl -d "" http://localhost:7071/api/user/doris/follows/alice + curl -d "" http://localhost:7071/api/user/doris/follows/bob + curl -d "" http://localhost:7071/api/user/doris/follows/charlie + +Finally, if Doris queries the timeline now, she will see all the messages of Alice, Bob, and Charlie, sorted by timestamp. + + curl http://localhost:7071/api/user/doris/timeline + + + diff --git a/samples/isolated-entities/Chirper/host.json b/samples/isolated-entities/Chirper/host.json new file mode 100644 index 000000000..ee5cf5f83 --- /dev/null +++ b/samples/isolated-entities/Chirper/host.json @@ -0,0 +1,12 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + } + } +} \ No newline at end of file diff --git a/samples/isolated-entities/InventorySample/.gitignore b/samples/isolated-entities/InventorySample/.gitignore new file mode 100644 index 000000000..1422b39dc --- /dev/null +++ b/samples/isolated-entities/InventorySample/.gitignore @@ -0,0 +1,13 @@ +# Build output +bin/ +obj/ + +# User-specific files +*.user +*.suo + +# Azure Functions local settings +local.settings.json + +# Visual Studio cache +.vs/ \ No newline at end of file diff --git a/samples/isolated-entities/InventorySample/ChargePayment.cs b/samples/isolated-entities/InventorySample/ChargePayment.cs new file mode 100644 index 000000000..b3dc1b7ea --- /dev/null +++ b/samples/isolated-entities/InventorySample/ChargePayment.cs @@ -0,0 +1,14 @@ +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask; +using InventorySample; + +public static class ChargePayment +{ + [Function(nameof(ChargePayment))] + public static Task Run([ActivityTrigger] OrderRequest order) + { + // Simulate payment processing — always succeed for now + Console.WriteLine($"Charging {order.Amount:C} for Order {order.OrderId}"); + return Task.FromResult(true); + } +} diff --git a/samples/isolated-entities/InventorySample/InventoryEndpoints.cs b/samples/isolated-entities/InventorySample/InventoryEndpoints.cs new file mode 100644 index 000000000..1ad862716 --- /dev/null +++ b/samples/isolated-entities/InventorySample/InventoryEndpoints.cs @@ -0,0 +1,77 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.DurableTask.Client; +using Microsoft.DurableTask.Entities; + +public static class InventoryEndpoints +{ + // POST /orders + [Function("StartOrder")] + public static async Task StartOrder( + [HttpTrigger(AuthorizationLevel.Function, "post", Route = "orders")] HttpRequestData req, + [DurableClient] DurableTaskClient client) + { + var order = await JsonSerializer.DeserializeAsync(req.Body, SerializerOptions()); + if (order is null) + { + var badRes = req.CreateResponse(HttpStatusCode.BadRequest); + await badRes.WriteStringAsync("Invalid order payload."); + return badRes; + } + + await client.ScheduleNewOrchestrationInstanceAsync("OrderOrchestrator", order); + + var res = req.CreateResponse(HttpStatusCode.Accepted); + await res.WriteStringAsync($"Order {order.OrderId} started."); + return res; + } + + // POST /inventory/setup + [Function("SetupInventory")] + public static async Task SetupInventory( + [HttpTrigger(AuthorizationLevel.Function, "post", Route = "inventory/setup")] HttpRequestData req, + [DurableClient] DurableTaskClient client) + { + var inventory = await JsonSerializer.DeserializeAsync>(req.Body, SerializerOptions()); + if (inventory is null) + { + var badRes = req.CreateResponse(HttpStatusCode.BadRequest); + await badRes.WriteStringAsync("Invalid inventory payload."); + return badRes; + } + + foreach (var (sku, qty) in inventory) + { + await client.Entities.SignalEntityAsync( + new EntityInstanceId("InventoryEntity", "store"), + "AddOrUpdate", + new Item(sku, qty)); + } + + var res = req.CreateResponse(HttpStatusCode.OK); + await res.WriteStringAsync("Inventory updated."); + return res; + } + + // GET /inventory + [Function("GetInventory")] + public static async Task GetInventory( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "inventory")] HttpRequestData req, + [DurableClient] DurableTaskClient client) + { + string instanceId = $"query-inventory-{Guid.NewGuid()}"; + + await client.ScheduleNewOrchestrationInstanceAsync("QueryInventory", instanceId); + + return await client.CreateCheckStatusResponseAsync(req, instanceId); + } + + private static JsonSerializerOptions SerializerOptions() => new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; +} diff --git a/samples/isolated-entities/InventorySample/InventoryEntity.cs b/samples/isolated-entities/InventorySample/InventoryEntity.cs new file mode 100644 index 000000000..932a2e044 --- /dev/null +++ b/samples/isolated-entities/InventorySample/InventoryEntity.cs @@ -0,0 +1,50 @@ +using Microsoft.DurableTask.Entities; +using Microsoft.Azure.Functions.Worker; +using InventorySample; + +public class InventoryEntity +{ + public Dictionary Stock { get; set; } = new(); + + [Function(nameof(InventoryEntity))] + public static Task HandleAsync([EntityTrigger] TaskEntityDispatcher dispatcher) + => dispatcher.DispatchAsync(); + + public List GetAll() + { + return Stock.Select(item => new Item(item.Key, item.Value)).ToList(); + } + + public void AddOrUpdate(Item update) + { + if (!Stock.ContainsKey(update.Sku)) + Stock[update.Sku] = 0; + + Stock[update.Sku] += update.Qty; + } + + public bool TryRemoveMany(List items) + { + foreach (var item in items) + if (!Stock.ContainsKey(item.Sku) || Stock[item.Sku] < item.Qty) + return false; + + foreach (var item in items) + Stock[item.Sku] -= item.Qty; + + return true; + } + + public void AddMany(List items) + { + foreach (var item in items) + { + if (!Stock.ContainsKey(item.Sku)) + Stock[item.Sku] = 0; + + Stock[item.Sku] += item.Qty; + } + } +} + +public record Item(string Sku, int Qty); diff --git a/samples/isolated-entities/InventorySample/InventorySample.csproj b/samples/isolated-entities/InventorySample/InventorySample.csproj new file mode 100644 index 000000000..d00c2c0c4 --- /dev/null +++ b/samples/isolated-entities/InventorySample/InventorySample.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + v4 + Exe + enable + enable + + + + + + + + + + + + + + diff --git a/samples/isolated-entities/InventorySample/OrderOrchestrator.cs b/samples/isolated-entities/InventorySample/OrderOrchestrator.cs new file mode 100644 index 000000000..4a3bbfc37 --- /dev/null +++ b/samples/isolated-entities/InventorySample/OrderOrchestrator.cs @@ -0,0 +1,40 @@ +using InventorySample; +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; + +public static class OrderOrchestrator +{ + [Function(nameof(OrderOrchestrator))] + public static async Task Run( + [OrchestrationTrigger] TaskOrchestrationContext context) + { + var input = context.GetInput()!; + var entityId = new EntityInstanceId(nameof(InventoryEntity), "store"); + + var stockOk = await context.Entities.CallEntityAsync( + entityId, "TryRemoveMany", input.Items); + + if (!stockOk) + return $"Order {input.OrderId} failed: not enough stock."; + + var paid = await context.CallActivityAsync("ChargePayment", input); + if (!paid) + { + await context.Entities.CallEntityAsync(entityId, "AddMany", input.Items); + return $"Order {input.OrderId} failed: payment declined."; + } + + await context.CallActivityAsync("SendEmail", input.Email); + return $"Order {input.OrderId} succeeded."; + } +} + +public record OrderRequest( + string OrderId, + List Items, + string Email, + decimal Amount +); + +public record OrderLine(string Sku, int Qty); diff --git a/samples/isolated-entities/InventorySample/Program.cs b/samples/isolated-entities/InventorySample/Program.cs new file mode 100644 index 000000000..704475c64 --- /dev/null +++ b/samples/isolated-entities/InventorySample/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var builder = FunctionsApplication.CreateBuilder(args); + +builder.ConfigureFunctionsWebApplication(); + +builder.Services + .AddApplicationInsightsTelemetryWorkerService() + .ConfigureFunctionsApplicationInsights(); + +builder.Build().Run(); diff --git a/samples/isolated-entities/InventorySample/Properties/serviceDependencies.json b/samples/isolated-entities/InventorySample/Properties/serviceDependencies.json new file mode 100644 index 000000000..df4dcc9d8 --- /dev/null +++ b/samples/isolated-entities/InventorySample/Properties/serviceDependencies.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "appInsights1": { + "type": "appInsights" + }, + "storage1": { + "type": "storage", + "connectionId": "AzureWebJobsStorage" + } + } +} \ No newline at end of file diff --git a/samples/isolated-entities/InventorySample/Properties/serviceDependencies.local.json b/samples/isolated-entities/InventorySample/Properties/serviceDependencies.local.json new file mode 100644 index 000000000..b804a2893 --- /dev/null +++ b/samples/isolated-entities/InventorySample/Properties/serviceDependencies.local.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "appInsights1": { + "type": "appInsights.sdk" + }, + "storage1": { + "type": "storage.emulator", + "connectionId": "AzureWebJobsStorage" + } + } +} \ No newline at end of file diff --git a/samples/isolated-entities/InventorySample/QueryInventory.cs b/samples/isolated-entities/InventorySample/QueryInventory.cs new file mode 100644 index 000000000..193379146 --- /dev/null +++ b/samples/isolated-entities/InventorySample/QueryInventory.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; + +public static class QueryInventory +{ + [Function("QueryInventory")] + public static async Task GetInventory( + [OrchestrationTrigger] TaskOrchestrationContext context) + { + var stock = await context.Entities.CallEntityAsync>( + new EntityInstanceId("InventoryEntity", "store"), + "GetAll"); + + Console.WriteLine(JsonSerializer.Serialize(stock)); + } +} diff --git a/samples/isolated-entities/InventorySample/README.md b/samples/isolated-entities/InventorySample/README.md new file mode 100644 index 000000000..9d199a21e --- /dev/null +++ b/samples/isolated-entities/InventorySample/README.md @@ -0,0 +1,84 @@ +# Inventory Management Sample - Azure Functions Durable Entities + +A sample inventory management system built with Azure Functions using Durable Entities and the isolated worker model. + +## Overview + +This sample demonstrates how to use Azure Durable Entities to manage inventory state in a distributed system. It implements a simple e-commerce scenario where orders are processed, inventory is checked, payments are charged, and confirmation emails are sent. + +## Prerequisites + +- .NET 8.0 +- Azure Functions Core Tools +- Azure Storage Account (for local development) + +## Running Locally + +1. Clone the repository +2. Navigate to the project directory +3. Ensure you have a `local.settings.json` file with Azure Storage configuration +4. Run the function app: + ``` + func start + ``` +5. The function app will start on `http://localhost:7071` + +## API Endpoints & Usage + +### 1. Setup Inventory First +Before processing orders, you need to set up your inventory: + +```bash +curl -X POST http://localhost:7071/api/inventory/setup \ + -H "Content-Type: application/json" \ + -d '{ + "LAPTOP": 10, + "MOUSE": 50, + "KEYBOARD": 25 + }' +``` + +**Response:** `Inventory updated.` + +### 2. Start an Order +Process a new order: + +```bash +curl -X POST http://localhost:7071/api/orders \ + -H "Content-Type: application/json" \ + -d '{ + "orderId": "ORD-001", + "items": [ + { "sku": "LAPTOP", "qty": 1 }, + { "sku": "MOUSE", "qty": 2 } + ], + "email": "customer@example.com", + "amount": 599.99 + }' +``` + +**Response:** `Order ORD-001 started.` + +### 3. Check Inventory Levels +Query current inventory: + +```bash +curl -X GET http://localhost:7071/api/inventory +``` + +**Response:** Prints the inventory to the console. + +## How It Works + +1. **Order Submission**: Orders are submitted via HTTP and processed by the `OrderOrchestrator` +2. **Inventory Check**: The orchestrator calls the `InventoryEntity` to check and reserve stock +3. **Payment Processing**: If stock is available, payment is processed via the `ChargePayment` activity +4. **Email Notification**: On successful payment, a confirmation email is sent via the `SendEmail` activity +5. **Rollback**: If payment fails, reserved inventory is released + +## Architecture + +- **InventoryEntity**: Durable entity managing product stock levels +- **OrderOrchestrator**: Orchestrates the order processing workflow +- **InventoryEndpoints**: HTTP triggers for API endpoints +- **Activities**: ChargePayment and SendEmail activity functions \ No newline at end of file diff --git a/samples/isolated-entities/InventorySample/SendEmail.cs b/samples/isolated-entities/InventorySample/SendEmail.cs new file mode 100644 index 000000000..49d2bf27a --- /dev/null +++ b/samples/isolated-entities/InventorySample/SendEmail.cs @@ -0,0 +1,13 @@ +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask; + +public static class SendEmail +{ + [Function(nameof(SendEmail))] + public static Task Run([ActivityTrigger] string email) + { + // Simulate sending an email + Console.WriteLine($"Order confirmation email sent to {email}"); + return Task.CompletedTask; + } +} diff --git a/samples/isolated-entities/InventorySample/host.json b/samples/isolated-entities/InventorySample/host.json new file mode 100644 index 000000000..c0eb2ff78 --- /dev/null +++ b/samples/isolated-entities/InventorySample/host.json @@ -0,0 +1,20 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + }, + "enableLiveMetricsFilters": true + } + }, + "extensions": { + "durableTask": { + "tracing": { + "distributedTracingEnabled": true, + "version": "V2" + } + } + } +} \ No newline at end of file