From 3adebf43c9a6fb9194d0c140c7982c4c955e38de Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Fri, 14 Mar 2025 12:51:08 +0000 Subject: [PATCH 1/7] Orleans JournaledGrain sample --- .../JournaledTodoList.AppHost.csproj | 24 ++++ .../JournaledTodoList.AppHost/Program.cs | 27 ++++ .../Properties/launchSettings.json | 29 +++++ .../appsettings.Development.json | 8 ++ .../appsettings.json | 9 ++ .../Extensions.cs | 122 ++++++++++++++++++ .../JournaledTodoList.ServiceDefaults.csproj | 22 ++++ .../Components/App.razor | 23 ++++ .../Components/Layout/MainLayout.razor | 26 ++++ .../Components/Layout/MainLayout.razor.css | 20 +++ .../Components/Layout/NavBar.razor | 17 +++ .../Components/Layout/NavBar.razor.cs | 29 +++++ .../Components/Pages/Error.razor | 36 ++++++ .../Components/Pages/HomePage.razor | 24 ++++ .../Components/Pages/HomePage.razor.cs | 38 ++++++ .../Components/Pages/TodoListPage.razor | 84 ++++++++++++ .../Components/Pages/TodoListPage.razor.cs | 67 ++++++++++ .../Components/Routes.razor | 12 ++ .../Components/_Imports.razor | 12 ++ .../Grains/Events/TodoItemAdded.cs | 8 ++ .../Grains/Events/TodoItemRemoved.cs | 7 + .../Grains/Events/TodoItemToggled.cs | 7 + .../Grains/Events/TodoItemUpdated.cs | 7 + .../Grains/Events/TodoListEvent.cs | 7 + .../Grains/ITodoListGrain.cs | 19 +++ .../Grains/ITodoListRegistryGrain.cs | 14 ++ .../Grains/ITodoListRegistryObserver.cs | 8 ++ .../Grains/TodoItem.cs | 4 + .../Grains/TodoList.cs | 6 + .../Grains/TodoListGrain.cs | 96 ++++++++++++++ .../Grains/TodoListRegistryGrain.cs | 66 ++++++++++ .../JournaledTodoList.WebApp.csproj | 22 ++++ .../JournaledTodoList.WebApp/Program.cs | 40 ++++++ .../Properties/launchSettings.json | 23 ++++ .../Services/TodoListService.cs | 75 +++++++++++ .../appsettings.Development.json | 8 ++ .../JournaledTodoList.WebApp/appsettings.json | 9 ++ .../JournaledTodoList.WebApp/wwwroot/app.css | 38 ++++++ .../JournaledTodoList/JournaledTodoList.sln | 37 ++++++ 39 files changed, 1130 insertions(+) create mode 100644 orleans/JournaledTodoList/JournaledTodoList.AppHost/JournaledTodoList.AppHost.csproj create mode 100644 orleans/JournaledTodoList/JournaledTodoList.AppHost/Program.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.AppHost/Properties/launchSettings.json create mode 100644 orleans/JournaledTodoList/JournaledTodoList.AppHost/appsettings.Development.json create mode 100644 orleans/JournaledTodoList/JournaledTodoList.AppHost/appsettings.json create mode 100644 orleans/JournaledTodoList/JournaledTodoList.ServiceDefaults/Extensions.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.ServiceDefaults/JournaledTodoList.ServiceDefaults.csproj create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/App.razor create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor.css create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/Error.razor create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Routes.razor create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/_Imports.razor create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemAdded.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemRemoved.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemToggled.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemUpdated.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListEvent.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListGrain.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryGrain.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryObserver.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoItem.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoList.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListRegistryGrain.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/JournaledTodoList.WebApp.csproj create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Program.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Properties/launchSettings.json create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/appsettings.Development.json create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/appsettings.json create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/wwwroot/app.css create mode 100644 orleans/JournaledTodoList/JournaledTodoList.sln diff --git a/orleans/JournaledTodoList/JournaledTodoList.AppHost/JournaledTodoList.AppHost.csproj b/orleans/JournaledTodoList/JournaledTodoList.AppHost/JournaledTodoList.AppHost.csproj new file mode 100644 index 00000000000..39de8f582e4 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.AppHost/JournaledTodoList.AppHost.csproj @@ -0,0 +1,24 @@ + + + + + + Exe + net9.0 + enable + enable + true + 1df8a68d-9c06-4d9a-98ed-f735876b11f7 + + + + + + + + + + + + + diff --git a/orleans/JournaledTodoList/JournaledTodoList.AppHost/Program.cs b/orleans/JournaledTodoList/JournaledTodoList.AppHost/Program.cs new file mode 100644 index 00000000000..5f3e032d932 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.AppHost/Program.cs @@ -0,0 +1,27 @@ +var builder = DistributedApplication.CreateBuilder(args); + +// Add the resources which you will use for Orleans clustering and +// grain state storage. +var sessionStorage = builder.AddAzureStorage("sessionStorage") + .RunAsEmulator(); +var persistentStorage = builder.AddAzureStorage("persistentStorage") + .RunAsEmulator(config => config.WithLifetime(ContainerLifetime.Persistent)); + +var clusteringTable = sessionStorage.AddTables("clustering"); +var grainStorage = persistentStorage.AddBlobs("grain-state"); + +// Add the Orleans resource to the Aspire DistributedApplication +// builder, then configure it with Azure Table Storage for clustering +// and Azure Blob Storage for grain storage. +var orleans = builder.AddOrleans("default") + .WithClustering(clusteringTable) + .WithGrainStorage("Default", grainStorage); + +builder.AddProject("webapp") + .WithReference(orleans) + .WithReplicas(3) + .WithExternalHttpEndpoints() + .WaitFor(clusteringTable) + .WaitFor(grainStorage); + +builder.Build().Run(); diff --git a/orleans/JournaledTodoList/JournaledTodoList.AppHost/Properties/launchSettings.json b/orleans/JournaledTodoList/JournaledTodoList.AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..71382477d85 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17222;http://localhost:15135", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21055", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22157" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15135", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19238", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20164" + } + } + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.AppHost/appsettings.Development.json b/orleans/JournaledTodoList/JournaledTodoList.AppHost/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.AppHost/appsettings.json b/orleans/JournaledTodoList/JournaledTodoList.AppHost/appsettings.json new file mode 100644 index 00000000000..31c092aa450 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.ServiceDefaults/Extensions.cs b/orleans/JournaledTodoList/JournaledTodoList.ServiceDefaults/Extensions.cs new file mode 100644 index 00000000000..14aa84b9b89 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.ServiceDefaults/Extensions.cs @@ -0,0 +1,122 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddMeter("Microsoft.Orleans"); + }) + .WithTracing(tracing => + { + tracing.AddSource("Microsoft.Orleans.Runtime"); + tracing.AddSource("Microsoft.Orleans.Application"); + + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation() + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.ServiceDefaults/JournaledTodoList.ServiceDefaults.csproj b/orleans/JournaledTodoList/JournaledTodoList.ServiceDefaults/JournaledTodoList.ServiceDefaults.csproj new file mode 100644 index 00000000000..24b1b4fee9e --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.ServiceDefaults/JournaledTodoList.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/App.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/App.razor new file mode 100644 index 00000000000..b46dac45008 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/App.razor @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor new file mode 100644 index 00000000000..1773cacc4bf --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor @@ -0,0 +1,26 @@ +@inherits LayoutComponentBase + +
+
+ +
+ +
+
+ @Body +
+
+
diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor.css b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor.css new file mode 100644 index 00000000000..60cec92d5e5 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor.css @@ -0,0 +1,20 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor new file mode 100644 index 00000000000..55698b583b0 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor @@ -0,0 +1,17 @@ + diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor.cs new file mode 100644 index 00000000000..a4ec802f187 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor.cs @@ -0,0 +1,29 @@ +using System.Collections.Immutable; +using JournaledTodoList.WebApp.Grains; +using JournaledTodoList.WebApp.Services; + +namespace JournaledTodoList.WebApp.Components.Layout; + +public partial class NavBar(TodoListService todoListService) : ITodoListRegistryObserver, IDisposable +{ + private IDisposable? subscription; + + private ImmutableArray TodoLists { get; set; } = []; + + protected override async Task OnInitializedAsync() + { + TodoLists = await todoListService.GetAllTodoListsAsync(); + subscription = await todoListService.SubscribeAsync(this); + } + + public void Dispose() + { + subscription?.Dispose(); + } + + async Task ITodoListRegistryObserver.OnTodoListsChanged(ImmutableArray todoLists) => await InvokeAsync(() => + { + TodoLists = todoLists; + StateHasChanged(); + }); +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/Error.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/Error.razor new file mode 100644 index 00000000000..576cc2d2f4d --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor new file mode 100644 index 00000000000..f84b405d021 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor @@ -0,0 +1,24 @@ +@page "/" +Todo Lists + +
+

My Todo Lists

+ +
+
+
+ + +
+
+
+ +
+ @foreach (var listId in todoLists) + { + + @listId + + } +
+
diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor.cs new file mode 100644 index 00000000000..848960eb7ca --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor.cs @@ -0,0 +1,38 @@ +using System.Collections.Immutable; +using JournaledTodoList.WebApp.Services; + +namespace JournaledTodoList.WebApp.Components.Pages; + +public partial class HomePage(TodoListService todoListService) +{ + private string newListName = ""; + private ImmutableArray todoLists = []; + + protected override async Task OnInitializedAsync() + { + todoLists = await todoListService.GetAllTodoListsAsync(); + await CreateNewList("Default"); + } + + private async Task CreateNewList(string listName) + { + var normalizedName = NormalizeListName(listName); + if (string.IsNullOrWhiteSpace(normalizedName) || todoLists.Contains(normalizedName)) + { + return; + } + + await todoListService.CreateTodoListAsync(normalizedName); + todoLists = await todoListService.GetAllTodoListsAsync(); + newListName = ""; + } + + private static string NormalizeListName(string name) + { + // Replace spaces and special characters to ensure valid URL + return name.Trim() + .Replace(" ", "-") + .Replace("/", "-") + .Replace("\\", "-"); + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor new file mode 100644 index 00000000000..3ff211e6ae5 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor @@ -0,0 +1,84 @@ +@page "/todolist/{ListId}" +Todo List - @ListId +
+
+
+

Todo List: @ListId

+
+
+

Event History

+
+
+ +
+
+
+
+
+ + +
+
+
+
+ +
+ Below you see a change feed for this lists todo items. +
+
+ +
+
+ @if (todoList is null) + { +
Loading...
+ } + else if (todoList.Items.IsEmpty) + { +
No items in this list yet. Add one above!
+ } + else + { +
+ @foreach (var item in todoList.Items) + { +
+
+ +
+ + +
+ } +
+ } +
+
+ @if (history.IsDefault) + { +
Loading history...
+ } + else if (history.IsEmpty) + { +
No history yet.
+ } + else + { +
+ @foreach (var evt in history.OrderByDescending(e => e.Timestamp)) + { +
+ @evt.Timestamp.ToLocalTime().ToString("g") +
@evt.GetDescription()
+
+ } +
+ } +
+
+
diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor.cs new file mode 100644 index 00000000000..8fbedc65777 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor.cs @@ -0,0 +1,67 @@ +using System.Collections.Immutable; +using JournaledTodoList.WebApp.Grains; +using JournaledTodoList.WebApp.Grains.Events; +using JournaledTodoList.WebApp.Services; +using Microsoft.AspNetCore.Components; + +namespace JournaledTodoList.WebApp.Components.Pages; + +public partial class TodoListPage(TodoListService todoService) +{ + private string newItemTitle = ""; + private TodoList? todoList; + private ImmutableArray history; + + [Parameter, EditorRequired] + public required string ListId { get; set; } + + protected override async Task OnParametersSetAsync() + { + if (todoList?.Name != ListId) + { + todoList = null; + await LoadTodoList(); + } + } + + private async Task LoadTodoList() + { + todoList = await todoService.GetTodoListAsync(ListId); + history = await todoService.GetTodoListHistoryAsync(ListId); + } + + private async Task AddItem() + { + if (string.IsNullOrWhiteSpace(newItemTitle)) + { + return; + } + + await todoService.AddTodoItemAsync(ListId, newItemTitle); + newItemTitle = ""; + await LoadTodoList(); + } + + private async Task UpdateItem(int itemId, string? title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return; + } + + await todoService.UpdateTodoItemAsync(ListId, itemId, title); + await LoadTodoList(); + } + + private async Task ToggleItem(int itemId) + { + await todoService.ToggleTodoItemAsync(ListId, itemId); + await LoadTodoList(); + } + + private async Task RemoveItem(int itemId) + { + await todoService.RemoveTodoItemAsync(ListId, itemId); + await LoadTodoList(); + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Routes.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Routes.razor new file mode 100644 index 00000000000..6d6b5d6b258 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Routes.razor @@ -0,0 +1,12 @@ + + + + + + + Not found + +

Sorry, there's nothing at this address.

+
+
+
diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/_Imports.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/_Imports.razor new file mode 100644 index 00000000000..1fa622ca8b7 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using JournaledTodoList.WebApp +@using JournaledTodoList.WebApp.Components +@using JournaledTodoList.WebApp.Grains +@using JournaledTodoList.WebApp.Services diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemAdded.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemAdded.cs new file mode 100644 index 00000000000..6d170bcf6a7 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemAdded.cs @@ -0,0 +1,8 @@ +namespace JournaledTodoList.WebApp.Grains.Events; + +[GenerateSerializer, Immutable] +public record class TodoItemAdded(int ItemId, DateTimeOffset Timestamp, string Title) + : TodoListEvent(ItemId, Timestamp) +{ + public override string GetDescription() => $"Added item {ItemId}: {Title}"; +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemRemoved.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemRemoved.cs new file mode 100644 index 00000000000..c0422882c24 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemRemoved.cs @@ -0,0 +1,7 @@ +namespace JournaledTodoList.WebApp.Grains.Events; + +[GenerateSerializer, Immutable] +public record class TodoItemRemoved(int ItemId, DateTimeOffset Timestamp) : TodoListEvent(ItemId, Timestamp) +{ + public override string GetDescription() => $"Removed item {ItemId}"; +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemToggled.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemToggled.cs new file mode 100644 index 00000000000..caaa547d9c3 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemToggled.cs @@ -0,0 +1,7 @@ +namespace JournaledTodoList.WebApp.Grains.Events; + +[GenerateSerializer, Immutable] +public record class TodoItemToggled(int ItemId, DateTimeOffset Timestamp) : TodoListEvent(ItemId, Timestamp) +{ + public override string GetDescription() => $"Toggled completion status of item {ItemId}"; +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemUpdated.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemUpdated.cs new file mode 100644 index 00000000000..8cc9194a030 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemUpdated.cs @@ -0,0 +1,7 @@ +namespace JournaledTodoList.WebApp.Grains.Events; + +[GenerateSerializer, Immutable] +public record class TodoItemUpdated(int ItemId, DateTimeOffset Timestamp, string Title) : TodoListEvent(ItemId, Timestamp) +{ + public override string GetDescription() => $"Updated item {ItemId}: {Title}"; +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListEvent.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListEvent.cs new file mode 100644 index 00000000000..b74e710a199 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListEvent.cs @@ -0,0 +1,7 @@ +namespace JournaledTodoList.WebApp.Grains.Events; + +[GenerateSerializer, Immutable] +public abstract record class TodoListEvent(int ItemId, DateTimeOffset Timestamp) +{ + public abstract string GetDescription(); +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListGrain.cs new file mode 100644 index 00000000000..25d7ffdad88 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListGrain.cs @@ -0,0 +1,19 @@ +using System.Collections.Immutable; +using JournaledTodoList.WebApp.Grains.Events; + +namespace JournaledTodoList.WebApp.Grains; + +public interface ITodoListGrain : IGrainWithStringKey +{ + Task GetTodoListAsync(); + + Task AddTodoItemAsync(string title); + + Task ToggleTodoItemAsync(int id); + + Task UpdateTodoItemAsync(int id, string title); + + Task RemoveTodoItemAsync(int id); + + Task> GetHistoryAsync(); +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryGrain.cs new file mode 100644 index 00000000000..e31990f7a65 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryGrain.cs @@ -0,0 +1,14 @@ +using System.Collections.Immutable; + +namespace JournaledTodoList.WebApp.Grains; + +public interface ITodoListRegistryGrain : IGrainWithStringKey +{ + Task RegisterTodoListAsync(string todoListId); + + Task> GetAllTodoListsAsync(); + + Task Subscribe(ITodoListRegistryObserver observer); + + Task Unsubscribe(ITodoListRegistryObserver observer); +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryObserver.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryObserver.cs new file mode 100644 index 00000000000..950506335bd --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryObserver.cs @@ -0,0 +1,8 @@ +using System.Collections.Immutable; + +namespace JournaledTodoList.WebApp.Grains; + +public interface ITodoListRegistryObserver : IGrainObserver +{ + Task OnTodoListsChanged(ImmutableArray todoLists); +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoItem.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoItem.cs new file mode 100644 index 00000000000..7123804804a --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoItem.cs @@ -0,0 +1,4 @@ +namespace JournaledTodoList.WebApp.Grains; + +[GenerateSerializer, Immutable] +public record class TodoItem(int Id, string Title, bool IsCompleted); diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoList.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoList.cs new file mode 100644 index 00000000000..087d8ce2088 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoList.cs @@ -0,0 +1,6 @@ +using System.Collections.Immutable; + +namespace JournaledTodoList.WebApp.Grains; + +[GenerateSerializer, Immutable] +public record class TodoList(string Name, ImmutableArray Items); diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs new file mode 100644 index 00000000000..cb92eeead07 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs @@ -0,0 +1,96 @@ +using System.Collections.Immutable; +using JournaledTodoList.WebApp.Grains.Events; +using Orleans.EventSourcing; + +namespace JournaledTodoList.WebApp.Grains; + +public sealed class TodoListGrain : JournaledGrain, ITodoListGrain +{ + public override async Task OnActivateAsync(CancellationToken cancellationToken) + { + var registry = GrainFactory.GetGrain("registry"); + await registry.RegisterTodoListAsync(this.GetPrimaryKeyString()); + } + + public Task GetTodoListAsync() + { + var list = new TodoList( + Name: this.GetPrimaryKeyString(), + Items: State.Items.Values.ToImmutableArray()); + + return Task.FromResult(list); + } + + public async Task AddTodoItemAsync(string title) + { + var evt = new TodoItemAdded( + Version, + DateTimeOffset.UtcNow, + title); + + RaiseEvent(evt); + await ConfirmEvents(); + } + + public async Task UpdateTodoItemAsync(int id, string title) + { + var evt = new TodoItemUpdated(id, DateTimeOffset.UtcNow, title); + RaiseEvent(evt); + await ConfirmEvents(); + } + + public async Task ToggleTodoItemAsync(int id) + { + var evt = new TodoItemToggled(id, DateTimeOffset.UtcNow); + RaiseEvent(evt); + await ConfirmEvents(); + } + + public async Task RemoveTodoItemAsync(int id) + { + var evt = new TodoItemRemoved(id, DateTimeOffset.UtcNow); + RaiseEvent(evt); + await ConfirmEvents(); + } + + public async Task> GetHistoryAsync() + { + var events = await RetrieveConfirmedEvents(0, Version); + return events.ToImmutableArray(); + } + + /// + /// The state container for . + /// NOTE: this has to be a mutable object. + /// + public sealed class TodoListProjection + { + public Dictionary Items { get; set; } = []; + + public void Apply(TodoItemAdded added) + { + Items.Add(added.ItemId, new TodoItem(added.ItemId, added.Title, false)); + } + + public void Apply(TodoItemUpdated updated) + { + if (Items.TryGetValue(updated.ItemId, out var item)) + { + Items[updated.ItemId] = item with { Title = updated.Title }; + } + } + + public void Apply(TodoItemToggled toggled) + { + if (Items.TryGetValue(toggled.ItemId, out var item)) + { + Items[toggled.ItemId] = item with { IsCompleted = !item.IsCompleted }; + } + } + + public void Apply(TodoItemRemoved removed) + { + Items.Remove(removed.ItemId); + } + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListRegistryGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListRegistryGrain.cs new file mode 100644 index 00000000000..a1f48cb9d78 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListRegistryGrain.cs @@ -0,0 +1,66 @@ +using System.Collections.Immutable; +using Orleans.Utilities; + +namespace JournaledTodoList.WebApp.Grains; + +public sealed class TodoListRegistryGrain( + [PersistentState("todoListRegistry", "Default")] + IPersistentState state, + ILogger logger) : Grain, ITodoListRegistryGrain +{ + private readonly ObserverManager observers = new( + TimeSpan.FromMinutes(5), + logger); + + public async Task RegisterTodoListAsync(string todoListId) + { + if (state.State.TodoListIds.Contains(todoListId)) + { + return; + } + + state.State.TodoListIds.Add(todoListId); + await state.WriteStateAsync(); + + // Notify observers + NotifyObservers(); + } + + public Task> GetAllTodoListsAsync() + { + return Task.FromResult(state.State.TodoListIds.ToImmutableArray()); + } + + public Task Subscribe(ITodoListRegistryObserver observer) + { + observers.Subscribe(observer, observer); + + // Send current state to new observer + observer.OnTodoListsChanged(state.State.TodoListIds.ToImmutableArray()); + + return Task.CompletedTask; + } + + public Task Unsubscribe(ITodoListRegistryObserver observer) + { + observers.Unsubscribe(observer); + return Task.CompletedTask; + } + + public override Task OnDeactivateAsync(DeactivationReason reason, CancellationToken cancellationToken) + { + observers.Clear(); + return base.OnDeactivateAsync(reason, cancellationToken); + } + + private void NotifyObservers() + { + var todoLists = state.State.TodoListIds.ToImmutableArray(); + observers.Notify(observer => observer.OnTodoListsChanged(todoLists)); + } + + public sealed class TodoListRegistry + { + public HashSet TodoListIds { get; set; } = []; + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/JournaledTodoList.WebApp.csproj b/orleans/JournaledTodoList/JournaledTodoList.WebApp/JournaledTodoList.WebApp.csproj new file mode 100644 index 00000000000..13fb14dcc42 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/JournaledTodoList.WebApp.csproj @@ -0,0 +1,22 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Program.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Program.cs new file mode 100644 index 00000000000..161a59ec6b9 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Program.cs @@ -0,0 +1,40 @@ +using JournaledTodoList.WebApp.Components; +using JournaledTodoList.WebApp.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.AddKeyedAzureTableClient("clustering"); +builder.AddKeyedAzureBlobClient("grain-state"); +builder.UseOrleans(siloBuilder => +{ + siloBuilder.AddLogStorageBasedLogConsistencyProviderAsDefault(); +}); +builder.Services.AddScoped(); + +// Add services to the container. +builder.Services + .AddRazorComponents() + .AddInteractiveServerComponents(); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHttpsRedirection(); + +app.UseAntiforgery(); + +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Properties/launchSettings.json b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Properties/launchSettings.json new file mode 100644 index 00000000000..3403eb20fed --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5169", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7111;http://localhost:5169", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs new file mode 100644 index 00000000000..04c911328a5 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs @@ -0,0 +1,75 @@ +using System.Collections.Immutable; +using JournaledTodoList.WebApp.Grains; +using JournaledTodoList.WebApp.Grains.Events; + +namespace JournaledTodoList.WebApp.Services; + +public class TodoListService(IGrainFactory grainFactory) +{ + public async Task SubscribeAsync(ITodoListRegistryObserver observer) + { + var registryGrain = grainFactory.GetGrain("registry"); + var observerRef = grainFactory.CreateObjectReference(observer); + await registryGrain.Subscribe(observerRef); + return new Subscription(observerRef, registryGrain); + } + + public Task> GetAllTodoListsAsync() + { + var registryGrain = grainFactory.GetGrain("registry"); + return registryGrain.GetAllTodoListsAsync(); + } + + public async Task CreateTodoListAsync(string listId) + { + // Just accessing the grain is enough to have it register itself + var grain = grainFactory.GetGrain(listId); + await grain.GetTodoListAsync(); + } + + public Task GetTodoListAsync(string listId) + { + var grain = grainFactory.GetGrain(listId); + return grain.GetTodoListAsync(); + } + + public Task> GetTodoListHistoryAsync(string listId) + { + var grain = grainFactory.GetGrain(listId); + return grain.GetHistoryAsync(); + } + + public Task AddTodoItemAsync(string listId, string title) + { + var grain = grainFactory.GetGrain(listId); + return grain.AddTodoItemAsync(title); + } + + public Task UpdateTodoItemAsync(string listId, int itemId, string title) + { + var grain = grainFactory.GetGrain(listId); + return grain.UpdateTodoItemAsync(itemId, title); + } + + public Task ToggleTodoItemAsync(string listId, int itemId) + { + var grain = grainFactory.GetGrain(listId); + return grain.ToggleTodoItemAsync(itemId); + } + + public Task RemoveTodoItemAsync(string listId, int itemId) + { + var grain = grainFactory.GetGrain(listId); + return grain.RemoveTodoItemAsync(itemId); + } + + private sealed class Subscription( + ITodoListRegistryObserver observerRef, + ITodoListRegistryGrain registryGrain) : IDisposable + { + public void Dispose() + { + registryGrain.Unsubscribe(observerRef); + } + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/appsettings.Development.json b/orleans/JournaledTodoList/JournaledTodoList.WebApp/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/appsettings.json b/orleans/JournaledTodoList/JournaledTodoList.WebApp/appsettings.json new file mode 100644 index 00000000000..10f68b8c8b4 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/wwwroot/app.css b/orleans/JournaledTodoList/JournaledTodoList.WebApp/wwwroot/app.css new file mode 100644 index 00000000000..53883578d45 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/wwwroot/app.css @@ -0,0 +1,38 @@ +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} \ No newline at end of file diff --git a/orleans/JournaledTodoList/JournaledTodoList.sln b/orleans/JournaledTodoList/JournaledTodoList.sln new file mode 100644 index 00000000000..bc4b24da5f4 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JournaledTodoList.AppHost", "JournaledTodoList.AppHost\JournaledTodoList.AppHost.csproj", "{631A2EBD-8FE8-484F-8B28-2412E2D75874}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JournaledTodoList.ServiceDefaults", "JournaledTodoList.ServiceDefaults\JournaledTodoList.ServiceDefaults.csproj", "{02724D00-B8E6-8A72-DB7E-3D8123F39C3A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JournaledTodoList.WebApp", "JournaledTodoList.WebApp\JournaledTodoList.WebApp.csproj", "{5CD7672B-C25A-E910-16FD-342A3D36E81B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {631A2EBD-8FE8-484F-8B28-2412E2D75874}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {631A2EBD-8FE8-484F-8B28-2412E2D75874}.Debug|Any CPU.Build.0 = Debug|Any CPU + {631A2EBD-8FE8-484F-8B28-2412E2D75874}.Release|Any CPU.ActiveCfg = Release|Any CPU + {631A2EBD-8FE8-484F-8B28-2412E2D75874}.Release|Any CPU.Build.0 = Release|Any CPU + {02724D00-B8E6-8A72-DB7E-3D8123F39C3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02724D00-B8E6-8A72-DB7E-3D8123F39C3A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02724D00-B8E6-8A72-DB7E-3D8123F39C3A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02724D00-B8E6-8A72-DB7E-3D8123F39C3A}.Release|Any CPU.Build.0 = Release|Any CPU + {5CD7672B-C25A-E910-16FD-342A3D36E81B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5CD7672B-C25A-E910-16FD-342A3D36E81B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5CD7672B-C25A-E910-16FD-342A3D36E81B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5CD7672B-C25A-E910-16FD-342A3D36E81B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {1B7DFB36-A7DB-4D2B-BA45-9C1ECB5D444A} + EndGlobalSection +EndGlobal From 958596897f8104e677a8987087ec78caafa8fb38 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Fri, 14 Mar 2025 18:07:03 +0000 Subject: [PATCH 2/7] Add timestamp to projection type --- .../JournaledTodoList.WebApp/Grains/TodoListGrain.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs index cb92eeead07..aea92e0b1f6 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs @@ -16,7 +16,8 @@ public Task GetTodoListAsync() { var list = new TodoList( Name: this.GetPrimaryKeyString(), - Items: State.Items.Values.ToImmutableArray()); + Items: State.Items.Values.ToImmutableArray(), + Timestamp: State.Timestamp); return Task.FromResult(list); } @@ -67,9 +68,12 @@ public sealed class TodoListProjection { public Dictionary Items { get; set; } = []; + public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.MinValue; + public void Apply(TodoItemAdded added) { Items.Add(added.ItemId, new TodoItem(added.ItemId, added.Title, false)); + Timestamp = added.Timestamp; } public void Apply(TodoItemUpdated updated) @@ -78,6 +82,7 @@ public void Apply(TodoItemUpdated updated) { Items[updated.ItemId] = item with { Title = updated.Title }; } + Timestamp = updated.Timestamp; } public void Apply(TodoItemToggled toggled) @@ -86,11 +91,13 @@ public void Apply(TodoItemToggled toggled) { Items[toggled.ItemId] = item with { IsCompleted = !item.IsCompleted }; } + Timestamp = toggled.Timestamp; } public void Apply(TodoItemRemoved removed) { Items.Remove(removed.ItemId); + Timestamp = removed.Timestamp; } } } From 7ea533a1b7282010ae3cbc725cb295ee53b61efa Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Fri, 14 Mar 2025 18:08:14 +0000 Subject: [PATCH 3/7] Ensure projection has been materialized before returning list --- .../JournaledTodoList.WebApp/Grains/TodoListGrain.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs index aea92e0b1f6..f7b0196ef40 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs @@ -12,14 +12,19 @@ public override async Task OnActivateAsync(CancellationToken cancellationToken) await registry.RegisterTodoListAsync(this.GetPrimaryKeyString()); } - public Task GetTodoListAsync() { + + public async Task GetTodoListAsync() + { + // Ensure we are State object is up to date before returning. + await RefreshNow(); + var list = new TodoList( Name: this.GetPrimaryKeyString(), Items: State.Items.Values.ToImmutableArray(), Timestamp: State.Timestamp); - return Task.FromResult(list); + return list; } public async Task AddTodoItemAsync(string title) From 47bc8a5b9f538901c17ebb9612ebc51aff86d991 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Fri, 14 Mar 2025 18:08:30 +0000 Subject: [PATCH 4/7] Add support for navigating a todo lists history --- .../Components/Layout/MainLayout.razor | 10 ++-- .../Components/Pages/TodoListPage.razor | 46 +++++++++++++------ .../Components/Pages/TodoListPage.razor.cs | 44 +++++++++++++++++- .../Grains/ITodoListGrain.cs | 8 +--- .../Grains/TodoList.cs | 5 +- .../Grains/TodoListGrain.cs | 25 ++++++++++ .../JournaledTodoList.WebApp/Program.cs | 4 +- .../Services/TodoListService.cs | 6 +++ 8 files changed, 116 insertions(+), 32 deletions(-) diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor index 1773cacc4bf..dada90be88b 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/MainLayout.razor @@ -1,6 +1,6 @@ @inherits LayoutComponentBase -
+
-
-
- @Body -
+
+ @Body
diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor index 3ff211e6ae5..11657deffd0 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor @@ -1,6 +1,6 @@ @page "/todolist/{ListId}" Todo List - @ListId -
+

Todo List: @ListId

@@ -12,23 +12,34 @@
-
+ @if (IsViewingHistory) + { + + } + else + {
-
+
- +
-
+ }
- Below you see a change feed for this lists todo items. +
-
-
+
+
@if (todoList is null) {
Loading...
@@ -46,19 +57,24 @@
+ aria-label="Toggles todo item" + disabled="@IsViewingHistory">
- + disabled="@(IsViewingHistory || item.IsCompleted)"> +
}
}
-
+
@if (history.IsDefault) {
Loading history...
@@ -72,10 +88,12 @@
@foreach (var evt in history.OrderByDescending(e => e.Timestamp)) { -
+
+ }
} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor.cs index 8fbedc65777..81ffdae56cf 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/TodoListPage.razor.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using JournaledTodoList.WebApp.Grains; using JournaledTodoList.WebApp.Grains.Events; using JournaledTodoList.WebApp.Services; @@ -11,6 +12,10 @@ public partial class TodoListPage(TodoListService todoService) private string newItemTitle = ""; private TodoList? todoList; private ImmutableArray history; + private DateTimeOffset? currentViewTimestamp; + + [MemberNotNullWhen(true, nameof(currentViewTimestamp))] + private bool IsViewingHistory => !history.IsDefaultOrEmpty && currentViewTimestamp < history[^1].Timestamp; [Parameter, EditorRequired] public required string ListId { get; set; } @@ -19,6 +24,7 @@ protected override async Task OnParametersSetAsync() { if (todoList?.Name != ListId) { + currentViewTimestamp = null; todoList = null; await LoadTodoList(); } @@ -32,7 +38,7 @@ private async Task LoadTodoList() private async Task AddItem() { - if (string.IsNullOrWhiteSpace(newItemTitle)) + if (string.IsNullOrWhiteSpace(newItemTitle) || IsViewingHistory) { return; } @@ -44,7 +50,7 @@ private async Task AddItem() private async Task UpdateItem(int itemId, string? title) { - if (string.IsNullOrWhiteSpace(title)) + if (string.IsNullOrWhiteSpace(title) || IsViewingHistory) { return; } @@ -55,13 +61,47 @@ private async Task UpdateItem(int itemId, string? title) private async Task ToggleItem(int itemId) { + if (IsViewingHistory) + { + return; + } + await todoService.ToggleTodoItemAsync(ListId, itemId); await LoadTodoList(); } private async Task RemoveItem(int itemId) { + if (IsViewingHistory) + { + return; + } + await todoService.RemoveTodoItemAsync(ListId, itemId); await LoadTodoList(); } + + private async Task ViewAtTimestamp(DateTimeOffset timestamp) + { + if (timestamp == history[^1].Timestamp) + { + currentViewTimestamp = null; + } + else + { + currentViewTimestamp = timestamp; + todoList = await todoService.GetTodoListAtTimestampAsync(ListId, timestamp); + } + } + + private async Task ReturnToCurrentVersion() + { + currentViewTimestamp = null; + await LoadTodoList(); + } + + private bool IsCurrentHistoryItem(TodoListEvent item) + => currentViewTimestamp.HasValue + ? item.Timestamp == currentViewTimestamp + : history[^1] == item; } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListGrain.cs index 25d7ffdad88..6d0c19f052a 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListGrain.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListGrain.cs @@ -6,14 +6,10 @@ namespace JournaledTodoList.WebApp.Grains; public interface ITodoListGrain : IGrainWithStringKey { Task GetTodoListAsync(); - + Task GetTodoListAtTimestampAsync(DateTimeOffset timestamp); Task AddTodoItemAsync(string title); - - Task ToggleTodoItemAsync(int id); - Task UpdateTodoItemAsync(int id, string title); - + Task ToggleTodoItemAsync(int id); Task RemoveTodoItemAsync(int id); - Task> GetHistoryAsync(); } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoList.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoList.cs index 087d8ce2088..536fd119205 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoList.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoList.cs @@ -3,4 +3,7 @@ namespace JournaledTodoList.WebApp.Grains; [GenerateSerializer, Immutable] -public record class TodoList(string Name, ImmutableArray Items); +public record class TodoList( + string Name, + ImmutableArray Items, + DateTimeOffset Timestamp); diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs index f7b0196ef40..6a34946c8d2 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs @@ -12,7 +12,32 @@ public override async Task OnActivateAsync(CancellationToken cancellationToken) await registry.RegisterTodoListAsync(this.GetPrimaryKeyString()); } + public async Task GetTodoListAtTimestampAsync(DateTimeOffset timestamp) { + // Get all events up to the current version + var allEvents = await RetrieveConfirmedEvents(0, Version); + + // Create a fresh projection and apply the filtered events + var historicalProjection = new TodoListProjection(); + foreach (var evt in allEvents.Where(e => e.Timestamp <= timestamp)) + { + switch (evt) + { + case TodoItemAdded added: historicalProjection.Apply(added); break; + case TodoItemUpdated updated: historicalProjection.Apply(updated); break; + case TodoItemToggled toggled: historicalProjection.Apply(toggled); break; + case TodoItemRemoved removed: historicalProjection.Apply(removed); break; + } + } + + // Return the historical state + return historicalProjection.Timestamp > DateTimeOffset.MinValue + ? new TodoList( + Name: this.GetPrimaryKeyString(), + Items: historicalProjection.Items.Values.ToImmutableArray(), + Timestamp: historicalProjection.Timestamp) + : null; + } public async Task GetTodoListAsync() { diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Program.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Program.cs index 161a59ec6b9..e08c1a54dbf 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Program.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Program.cs @@ -12,9 +12,7 @@ }); builder.Services.AddScoped(); -// Add services to the container. -builder.Services - .AddRazorComponents() +builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); var app = builder.Build(); diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs index 04c911328a5..fab01ee9cf3 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs @@ -33,6 +33,12 @@ public Task GetTodoListAsync(string listId) return grain.GetTodoListAsync(); } + public Task GetTodoListAtTimestampAsync(string listId, DateTimeOffset timestamp) + { + var grain = grainFactory.GetGrain(listId); + return grain.GetTodoListAtTimestampAsync(timestamp); + } + public Task> GetTodoListHistoryAsync(string listId) { var grain = grainFactory.GetGrain(listId); From ef769cdd72bc3b38407ea64a102b0ccb8263e2b8 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Mon, 17 Mar 2025 13:39:10 +0000 Subject: [PATCH 5/7] Allow todo lists to have different names than id's - change registry grain to be state-based journaled --- .../Components/Layout/NavBar.razor | 6 +- .../Components/Layout/NavBar.razor.cs | 5 +- .../Components/Pages/HomePage.razor | 6 +- .../Components/Pages/HomePage.razor.cs | 36 +++++------ .../JournaledTodoList.WebApp/Constants.cs | 4 ++ .../Grains/Events/TodoItemAdded.cs | 2 +- .../Grains/Events/TodoItemRemoved.cs | 2 +- .../Grains/Events/TodoItemToggled.cs | 2 +- .../Grains/Events/TodoItemUpdated.cs | 2 +- .../Grains/Events/TodoListEvent.cs | 2 +- .../Grains/Events/TodoListNameChanged.cs | 7 +++ .../Grains/ITodoListGrain.cs | 8 +++ .../Grains/ITodoListRegistryGrain.cs | 4 +- .../Grains/ITodoListRegistryObserver.cs | 4 +- .../Grains/TodoListGrain.cs | 43 +++++++------ .../Grains/TodoListReference.cs | 4 ++ .../Grains/TodoListRegistryGrain.cs | 60 ++++++++++++------- .../JournaledTodoList.WebApp/Program.cs | 1 + .../Services/TodoListService.cs | 23 +++++-- 19 files changed, 143 insertions(+), 78 deletions(-) create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Constants.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListNameChanged.cs create mode 100644 orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListReference.cs diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor index 55698b583b0..14e4d93f3e9 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor @@ -5,11 +5,11 @@ Home - @foreach (var listId in TodoLists) + @foreach (var list in TodoLists) { } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor.cs index a4ec802f187..5fd6c2ce8ae 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Layout/NavBar.razor.cs @@ -8,11 +8,10 @@ public partial class NavBar(TodoListService todoListService) : ITodoListRegistry { private IDisposable? subscription; - private ImmutableArray TodoLists { get; set; } = []; + private ImmutableArray TodoLists { get; set; } = []; protected override async Task OnInitializedAsync() { - TodoLists = await todoListService.GetAllTodoListsAsync(); subscription = await todoListService.SubscribeAsync(this); } @@ -21,7 +20,7 @@ public void Dispose() subscription?.Dispose(); } - async Task ITodoListRegistryObserver.OnTodoListsChanged(ImmutableArray todoLists) => await InvokeAsync(() => + async Task ITodoListRegistryObserver.OnTodoListsChanged(ImmutableArray todoLists) => await InvokeAsync(() => { TodoLists = todoLists; StateHasChanged(); diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor index f84b405d021..05ba5a10410 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor @@ -14,10 +14,10 @@
- @foreach (var listId in todoLists) + @foreach (var list in TodoLists) { - - @listId + + @list.Name }
diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor.cs index 848960eb7ca..f8eb7cb2027 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Components/Pages/HomePage.razor.cs @@ -1,38 +1,40 @@ using System.Collections.Immutable; +using JournaledTodoList.WebApp.Grains; using JournaledTodoList.WebApp.Services; namespace JournaledTodoList.WebApp.Components.Pages; -public partial class HomePage(TodoListService todoListService) +public partial class HomePage(TodoListService todoListService) : ITodoListRegistryObserver, IDisposable { + private IDisposable? subscription; private string newListName = ""; - private ImmutableArray todoLists = []; + + private ImmutableArray TodoLists { get; set; } = []; protected override async Task OnInitializedAsync() { - todoLists = await todoListService.GetAllTodoListsAsync(); - await CreateNewList("Default"); + subscription = await todoListService.SubscribeAsync(this); } + public void Dispose() + { + subscription?.Dispose(); + } + + async Task ITodoListRegistryObserver.OnTodoListsChanged(ImmutableArray todoLists) => await InvokeAsync(() => + { + TodoLists = todoLists; + StateHasChanged(); + }); + private async Task CreateNewList(string listName) { - var normalizedName = NormalizeListName(listName); - if (string.IsNullOrWhiteSpace(normalizedName) || todoLists.Contains(normalizedName)) + if (string.IsNullOrWhiteSpace(listName) || TodoLists.Any(x => x.Name == listName)) { return; } - await todoListService.CreateTodoListAsync(normalizedName); - todoLists = await todoListService.GetAllTodoListsAsync(); + await todoListService.CreateTodoListAsync(listName); newListName = ""; } - - private static string NormalizeListName(string name) - { - // Replace spaces and special characters to ensure valid URL - return name.Trim() - .Replace(" ", "-") - .Replace("/", "-") - .Replace("\\", "-"); - } } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Constants.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Constants.cs new file mode 100644 index 00000000000..3b06331580d --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Constants.cs @@ -0,0 +1,4 @@ +public static class Constants +{ + public const string StateStorageProviderName = "StateStorage"; +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemAdded.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemAdded.cs index 6d170bcf6a7..62a70b369d4 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemAdded.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemAdded.cs @@ -2,7 +2,7 @@ [GenerateSerializer, Immutable] public record class TodoItemAdded(int ItemId, DateTimeOffset Timestamp, string Title) - : TodoListEvent(ItemId, Timestamp) + : TodoListEvent(Timestamp) { public override string GetDescription() => $"Added item {ItemId}: {Title}"; } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemRemoved.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemRemoved.cs index c0422882c24..04f1559812c 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemRemoved.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemRemoved.cs @@ -1,7 +1,7 @@ namespace JournaledTodoList.WebApp.Grains.Events; [GenerateSerializer, Immutable] -public record class TodoItemRemoved(int ItemId, DateTimeOffset Timestamp) : TodoListEvent(ItemId, Timestamp) +public record class TodoItemRemoved(int ItemId, DateTimeOffset Timestamp) : TodoListEvent(Timestamp) { public override string GetDescription() => $"Removed item {ItemId}"; } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemToggled.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemToggled.cs index caaa547d9c3..b7a915048ee 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemToggled.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemToggled.cs @@ -1,7 +1,7 @@ namespace JournaledTodoList.WebApp.Grains.Events; [GenerateSerializer, Immutable] -public record class TodoItemToggled(int ItemId, DateTimeOffset Timestamp) : TodoListEvent(ItemId, Timestamp) +public record class TodoItemToggled(int ItemId, DateTimeOffset Timestamp) : TodoListEvent(Timestamp) { public override string GetDescription() => $"Toggled completion status of item {ItemId}"; } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemUpdated.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemUpdated.cs index 8cc9194a030..da235161aa7 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemUpdated.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoItemUpdated.cs @@ -1,7 +1,7 @@ namespace JournaledTodoList.WebApp.Grains.Events; [GenerateSerializer, Immutable] -public record class TodoItemUpdated(int ItemId, DateTimeOffset Timestamp, string Title) : TodoListEvent(ItemId, Timestamp) +public record class TodoItemUpdated(int ItemId, DateTimeOffset Timestamp, string Title) : TodoListEvent(Timestamp) { public override string GetDescription() => $"Updated item {ItemId}: {Title}"; } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListEvent.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListEvent.cs index b74e710a199..3407ac361ee 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListEvent.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListEvent.cs @@ -1,7 +1,7 @@ namespace JournaledTodoList.WebApp.Grains.Events; [GenerateSerializer, Immutable] -public abstract record class TodoListEvent(int ItemId, DateTimeOffset Timestamp) +public abstract record class TodoListEvent(DateTimeOffset Timestamp) { public abstract string GetDescription(); } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListNameChanged.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListNameChanged.cs new file mode 100644 index 00000000000..fe90e337917 --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/Events/TodoListNameChanged.cs @@ -0,0 +1,7 @@ +namespace JournaledTodoList.WebApp.Grains.Events; + +[GenerateSerializer, Immutable] +public record class TodoListNameChanged(string Name, DateTimeOffset Timestamp) : TodoListEvent(Timestamp) +{ + public override string GetDescription() => $"Todo list name changed to {Name}"; +} diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListGrain.cs index 6d0c19f052a..5be8886df9a 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListGrain.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListGrain.cs @@ -6,10 +6,18 @@ namespace JournaledTodoList.WebApp.Grains; public interface ITodoListGrain : IGrainWithStringKey { Task GetTodoListAsync(); + Task GetTodoListAtTimestampAsync(DateTimeOffset timestamp); + Task AddTodoItemAsync(string title); + Task UpdateTodoItemAsync(int id, string title); + Task ToggleTodoItemAsync(int id); + Task RemoveTodoItemAsync(int id); + + Task SetNameAsync(string listName); + Task> GetHistoryAsync(); } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryGrain.cs index e31990f7a65..5b285e0d820 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryGrain.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryGrain.cs @@ -4,9 +4,9 @@ namespace JournaledTodoList.WebApp.Grains; public interface ITodoListRegistryGrain : IGrainWithStringKey { - Task RegisterTodoListAsync(string todoListId); + Task RegisterTodoListAsync(TodoListReference todoListReference); - Task> GetAllTodoListsAsync(); + Task> GetAllTodoListsAsync(); Task Subscribe(ITodoListRegistryObserver observer); diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryObserver.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryObserver.cs index 950506335bd..cda0f551338 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryObserver.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/ITodoListRegistryObserver.cs @@ -1,8 +1,8 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; namespace JournaledTodoList.WebApp.Grains; public interface ITodoListRegistryObserver : IGrainObserver { - Task OnTodoListsChanged(ImmutableArray todoLists); + Task OnTodoListsChanged(ImmutableArray todoLists); } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs index 6a34946c8d2..0c794d36f6f 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs @@ -6,35 +6,29 @@ namespace JournaledTodoList.WebApp.Grains; public sealed class TodoListGrain : JournaledGrain, ITodoListGrain { - public override async Task OnActivateAsync(CancellationToken cancellationToken) + public async Task> GetHistoryAsync() { - var registry = GrainFactory.GetGrain("registry"); - await registry.RegisterTodoListAsync(this.GetPrimaryKeyString()); + var events = await RetrieveConfirmedEvents(0, Version); + return events.ToImmutableArray(); } public async Task GetTodoListAtTimestampAsync(DateTimeOffset timestamp) { // Get all events up to the current version - var allEvents = await RetrieveConfirmedEvents(0, Version); + var allEvents = await GetHistoryAsync(); // Create a fresh projection and apply the filtered events var historicalProjection = new TodoListProjection(); foreach (var evt in allEvents.Where(e => e.Timestamp <= timestamp)) { - switch (evt) - { - case TodoItemAdded added: historicalProjection.Apply(added); break; - case TodoItemUpdated updated: historicalProjection.Apply(updated); break; - case TodoItemToggled toggled: historicalProjection.Apply(toggled); break; - case TodoItemRemoved removed: historicalProjection.Apply(removed); break; - } + TransitionState(historicalProjection, evt); } - // Return the historical state + // Only return a TodoList if there were events that matched the timestamp. return historicalProjection.Timestamp > DateTimeOffset.MinValue ? new TodoList( Name: this.GetPrimaryKeyString(), - Items: historicalProjection.Items.Values.ToImmutableArray(), + Items: historicalProjection.Items.Values.OrderBy(x => x.Id).ToImmutableArray(), Timestamp: historicalProjection.Timestamp) : null; } @@ -46,7 +40,7 @@ public async Task GetTodoListAsync() var list = new TodoList( Name: this.GetPrimaryKeyString(), - Items: State.Items.Values.ToImmutableArray(), + Items: State.Items.Values.OrderBy(x => x.Id).ToImmutableArray(), Timestamp: State.Timestamp); return list; @@ -84,10 +78,17 @@ public async Task RemoveTodoItemAsync(int id) await ConfirmEvents(); } - public async Task> GetHistoryAsync() + public async Task SetNameAsync(string listName) { - var events = await RetrieveConfirmedEvents(0, Version); - return events.ToImmutableArray(); + var evt = new TodoListNameChanged(listName, DateTimeOffset.UtcNow); + RaiseEvent(evt); + await ConfirmEvents(); + + // Publish list with new name to todo list registry. + // The registry only cares about the id and name of the list, + // so this is the only place where we need to interact with the registry. + var registry = GrainFactory.GetGrain("registry"); + await registry.RegisterTodoListAsync(new TodoListReference(this.GetPrimaryKeyString(), listName)); } /// @@ -100,6 +101,8 @@ public sealed class TodoListProjection public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.MinValue; + public string Name { get; set; } = string.Empty; + public void Apply(TodoItemAdded added) { Items.Add(added.ItemId, new TodoItem(added.ItemId, added.Title, false)); @@ -129,5 +132,11 @@ public void Apply(TodoItemRemoved removed) Items.Remove(removed.ItemId); Timestamp = removed.Timestamp; } + + public void Apply(TodoListNameChanged nameChanged) + { + Name = nameChanged.Name; + Timestamp = nameChanged.Timestamp; + } } } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListReference.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListReference.cs new file mode 100644 index 00000000000..6847523b53c --- /dev/null +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListReference.cs @@ -0,0 +1,4 @@ +namespace JournaledTodoList.WebApp.Grains; + +[GenerateSerializer, Immutable] +public record class TodoListReference(string Id, string Name); diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListRegistryGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListRegistryGrain.cs index a1f48cb9d78..e5acf54f73a 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListRegistryGrain.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListRegistryGrain.cs @@ -1,43 +1,61 @@ using System.Collections.Immutable; +using Orleans.EventSourcing; +using Orleans.Providers; using Orleans.Utilities; namespace JournaledTodoList.WebApp.Grains; -public sealed class TodoListRegistryGrain( - [PersistentState("todoListRegistry", "Default")] - IPersistentState state, - ILogger logger) : Grain, ITodoListRegistryGrain +/// +/// A "state" based Journaled Grain. It does not save the events, just the state. +/// +[LogConsistencyProvider(ProviderName = Constants.StateStorageProviderName)] +public sealed class TodoListRegistryGrain(ILogger logger) + : JournaledGrain + , ITodoListRegistryGrain { private readonly ObserverManager observers = new( TimeSpan.FromMinutes(5), logger); - public async Task RegisterTodoListAsync(string todoListId) + public async Task RegisterTodoListAsync(TodoListReference todoListReference) { - if (state.State.TodoListIds.Contains(todoListId)) + if (State.TodoLists.Contains(todoListReference)) { return; } - state.State.TodoListIds.Add(todoListId); - await state.WriteStateAsync(); + RaiseEvent(todoListReference); + await ConfirmEvents(); + await NotifyObservers(); + } + + // Instead of having Apply methods in TodoListRegistry, we can override + // the TransitionState method and update the state here. + protected override void TransitionState(TodoListRegistry state, TodoListReference @event) + { + // If there is an existing item with the same Id, replace it with the new item, + // ensuring the order of the items are kept. + var existingList = state.TodoLists.FirstOrDefault(x => x.Id == @event.Id); - // Notify observers - NotifyObservers(); + if (existingList is not null) + { + state.TodoLists = state.TodoLists.Replace(existingList, @event); + } + else + { + state.TodoLists = state.TodoLists.Add(@event); + } } - public Task> GetAllTodoListsAsync() + public Task> GetAllTodoListsAsync() { - return Task.FromResult(state.State.TodoListIds.ToImmutableArray()); + return Task.FromResult(State.TodoLists); } public Task Subscribe(ITodoListRegistryObserver observer) { observers.Subscribe(observer, observer); - - // Send current state to new observer - observer.OnTodoListsChanged(state.State.TodoListIds.ToImmutableArray()); - + observer.OnTodoListsChanged(State.TodoLists); return Task.CompletedTask; } @@ -53,14 +71,12 @@ public override Task OnDeactivateAsync(DeactivationReason reason, CancellationTo return base.OnDeactivateAsync(reason, cancellationToken); } - private void NotifyObservers() - { - var todoLists = state.State.TodoListIds.ToImmutableArray(); - observers.Notify(observer => observer.OnTodoListsChanged(todoLists)); - } + private Task NotifyObservers() + => observers.Notify(observer => observer.OnTodoListsChanged(State.TodoLists)); + [GenerateSerializer, Immutable] public sealed class TodoListRegistry { - public HashSet TodoListIds { get; set; } = []; + public ImmutableArray TodoLists { get; set; } = []; } } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Program.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Program.cs index e08c1a54dbf..d95994b0eaf 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Program.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Program.cs @@ -9,6 +9,7 @@ builder.UseOrleans(siloBuilder => { siloBuilder.AddLogStorageBasedLogConsistencyProviderAsDefault(); + siloBuilder.AddStateStorageBasedLogConsistencyProvider(name: Constants.StateStorageProviderName); }); builder.Services.AddScoped(); diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs index fab01ee9cf3..e55f147cd29 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Web; using JournaledTodoList.WebApp.Grains; using JournaledTodoList.WebApp.Grains.Events; @@ -14,17 +15,31 @@ public async Task SubscribeAsync(ITodoListRegistryObserver observer return new Subscription(observerRef, registryGrain); } - public Task> GetAllTodoListsAsync() + public Task> GetTodoListReferencesAsync() { var registryGrain = grainFactory.GetGrain("registry"); return registryGrain.GetAllTodoListsAsync(); } - public async Task CreateTodoListAsync(string listId) + public async Task CreateTodoListAsync(string listName) { - // Just accessing the grain is enough to have it register itself + var listId = NormalizeListName(listName); var grain = grainFactory.GetGrain(listId); - await grain.GetTodoListAsync(); + await grain.SetNameAsync(listName); + + static string NormalizeListName(string name) + { + // Replace spaces and special characters to ensure valid URL + var normalized = name + .Trim() + .Replace(' ', '-') + .Replace('/', '-') + .Replace('\\', '-') + .ToLowerInvariant(); + + // Encode remaining bits for good measure! + return HttpUtility.UrlEncode(normalized); + } } public Task GetTodoListAsync(string listId) From 2de490cc15ac8bbe653813680e604059dccd2777 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Mon, 17 Mar 2025 14:46:09 +0000 Subject: [PATCH 6/7] Introduce constant for todo list registry id --- .../JournaledTodoList/JournaledTodoList.WebApp/Constants.cs | 2 ++ .../JournaledTodoList.WebApp/Grains/TodoListGrain.cs | 2 +- .../JournaledTodoList.WebApp/Services/TodoListService.cs | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Constants.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Constants.cs index 3b06331580d..fc242367cb9 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Constants.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Constants.cs @@ -1,4 +1,6 @@ public static class Constants { public const string StateStorageProviderName = "StateStorage"; + + public const string TodoListRegistryId = "registry"; } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs index 0c794d36f6f..9ebcee7ed9c 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs @@ -87,7 +87,7 @@ public async Task SetNameAsync(string listName) // Publish list with new name to todo list registry. // The registry only cares about the id and name of the list, // so this is the only place where we need to interact with the registry. - var registry = GrainFactory.GetGrain("registry"); + var registry = GrainFactory.GetGrain(Constants.TodoListRegistryId); await registry.RegisterTodoListAsync(new TodoListReference(this.GetPrimaryKeyString(), listName)); } diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs index e55f147cd29..15914b3315d 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Services/TodoListService.cs @@ -9,7 +9,7 @@ public class TodoListService(IGrainFactory grainFactory) { public async Task SubscribeAsync(ITodoListRegistryObserver observer) { - var registryGrain = grainFactory.GetGrain("registry"); + var registryGrain = grainFactory.GetGrain(Constants.TodoListRegistryId); var observerRef = grainFactory.CreateObjectReference(observer); await registryGrain.Subscribe(observerRef); return new Subscription(observerRef, registryGrain); @@ -17,7 +17,7 @@ public async Task SubscribeAsync(ITodoListRegistryObserver observer public Task> GetTodoListReferencesAsync() { - var registryGrain = grainFactory.GetGrain("registry"); + var registryGrain = grainFactory.GetGrain(Constants.TodoListRegistryId); return registryGrain.GetAllTodoListsAsync(); } From 90ba44e4162fb24a0716ae27a88568d940652922 Mon Sep 17 00:00:00 2001 From: Egil Hansen Date: Mon, 17 Mar 2025 14:48:02 +0000 Subject: [PATCH 7/7] RefreshNow is not needed when OnActivateAsync is not overridden --- .../JournaledTodoList.WebApp/Grains/TodoListGrain.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs index 9ebcee7ed9c..922e6577291 100644 --- a/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs +++ b/orleans/JournaledTodoList/JournaledTodoList.WebApp/Grains/TodoListGrain.cs @@ -35,9 +35,6 @@ public async Task> GetHistoryAsync() public async Task GetTodoListAsync() { - // Ensure we are State object is up to date before returning. - await RefreshNow(); - var list = new TodoList( Name: this.GetPrimaryKeyString(), Items: State.Items.Values.OrderBy(x => x.Id).ToImmutableArray(),